Vorwort
Liebe Studierende,
herzlich willkommen zur Vorlesung “Programmierkurs für Chemiker”. In diesem Kurs werden Sie die Grundlagen des Programmierens mit der Programmiersprache Python erlernen und viele nützliche numerische Algorithmen für die Chemie und darüber hinaus implementieren.
Diese Veranstaltung findet
- dienstags von 10:15 bis 11:45 Uhr und
- donnerstags von 14:15 bis 15:45 Uhr
im Seminarraum 00.006 des Instituts für Theoretische Chemie (Campus Hubland Nord) statt.
Syllabus
Ein vorläufiger Syllabus ist unten dargestellt. Dieser wird im Laufe des Semesters entsprechend dem Fortschritt der Vorlesung kontinuierlich aktualisiert.
| Woche | Wochentag | Datum | Typ | Thema |
|---|---|---|---|---|
| 1 | Di. | 16.04. | Einführung | Kursüberblick und Installation von Python |
| 1 | Do. | 18.04. | Vorlesung | Regression I |
| 2 | Di. | 23.04. | Vorlesung | Regression II |
| 2 | Do. | 25.04. | Vorlesung | Regression III |
| 3 | Di. | 30.04. | Übung | Regression |
| 3 | Do. | 02.05. | Vorlesung | Differentialgleichung I |
| 4 | Di. | 07.05. | Vorlesung | Differentialgleichung II |
| 4 | Feiertag | Christi Himmelfahrt | ||
| 5 | Di. | 14.05. | Vorlesung | Differentialgleichung III |
| 5 | Do. | 16.05. | Übung | Differentialgleichung |
| 6 | Vorlesungsfrei | Pfingsten | ||
| 6 | Do. | 23.05. | Vorlesung | Fourier-Analyse |
| 7 | Di. | 28.05. | Übung | Fourier-Analyse |
| 7 | Feiertag | Fronleichnam | ||
| 8 | Di. | 04.06. | Vorlesung | EVD und SVD I |
| 8 | Do. | 06.06. | Vorlesung | EVD und SVD II |
| 9 | Di. | 11.06. | Vorlesung | EVD und SVD III |
| 9 | Do. | 13.06. | Übung | EVD und SVD |
| 10 | Vorlesungsfrei | ChemCup | ||
| 10 | Do. | 20.06. | Vorlesung | Maschinelles Lernen I |
| 11 | Di. | 25.06. | Vorlesung | Maschinelles Lernen II |
| 11 | Do. | 27.06. | Vorlesung | Maschinelles Lernen III |
| 12 | Di. | 02.07. | Übung | Maschinelles Lernen |
| 12 | Do. | 04.07. | Vorlesung | Neuronale Netzwerke I |
| 13 | Di. | 09.07. | Vorlesung | Neuronale Netzwerke II |
| 13 | Do. | 11.07. | Vorlesung | Neuronale Netzwerke III |
| 14 | Di. | 16.07. | Übung | Neuronale Netzwerke |
| 14 | Do. | 18.07. | Vorlesung | Ausblick, Fragestunde und Klausurvorbereitung |
| Fr. | 09.08. | Klausur |
Motivation
Warum sollte ich im Chemie-Studium programmieren lernen?
Im 21. Jahrhundert wird Programmieren zu einer immer wichtigeren Fähigkeit. Dies gilt nicht nur für die Informatik, sondern auch für die Chemie, Physik, Biologie und viele andere Wissenschaften. Die Kenntnis von Programmiersprachen kann helfen, eine Vielzahl von Problemen effizienter zu lösen und repetitive Aufgaben zu automatisieren. Darüber hinaus können Programmiersprachen verwendet werden, um Daten zu visualisieren und zu analysieren, was ein wichtiger Bestandteil wissenschaftlicher Arbeit ist.
Warum Python?
Python ist eine der am weitesten verbreiteten Programmiersprachen und besonders anfängerfreundlich. Die folgende Abbildung zeigt die Beliebtheit einiger Programmiersprachen gemäß dem PopularitY of Programming Language (PYPL) Index (Stand Jan. 2024).
Beliebtheit von Programmiersprachen. Entnommen aus dem
PYPL Index.
Ein Grund dafür ist, dass Python oft deutlich weniger Code als kompilierte Sprachen wie Java oder C/C++ benötigt, um die gleichen Algorithmen zu implementieren.
Programmlänge, gemessen in der Anzahl der nicht-kommentierten Codezeilen (LOC).
Darüber hinaus ist es oft möglich, die gleiche Aufgabe mit einer Skriptsprache wie Python in deutlich kürzerer Zeit zu lösen als mit einer kompilierten Sprache.
Entwicklungszeit, um eine bestimmte Programmieraufgabe zu lösen,
gemessen in Stunden.
Auf der anderen Seite argumentieren einige Leute, dass Python eine langsame Sprache ist. Dies ist bis zu einem gewissen Grad wahr. Im folgenden betrachten wir einen Grund, warum Python als langsam angesehen wird, aber auch, warum Python trotzdem sehr performant sein kann.
Python ist (auch) eine interpretierte Sprache
Python selbst ist ein C-Programm, das den Quellcode zuerst in sogenannten Bytecode kompiliert und diesen dann interpretiert und ausführt. Dies steht im Gegensatz zu kompilierten Sprachen wie C, C++, Rust, etc., bei denen der Quellcode in Maschinencode kompiliert wird. Der Compiler kann viele Optimierungen am Code vornehmen, was zu einer kürzeren Laufzeit führt.
Beispiel
Dieses Verhalten kann anhand eines einfachen Beispiels gezeigt werden. Im Folgenden sehen Sie eine naive Implementierung, die alle ungeraden Zahlen bis 100 Millionen aufsummiert:
s = 0
for i in range(100_000_000):
if i % 2 == 1:
s += i
Dieser Code benötigt auf dem Computer des Autors etwa 8 Sekunden. Nun wird der gleiche Algorithmus in einer kompilierten Sprache (in diesem Fall Rust) implementiert, um den Einfluss des Compilers zu zeigen.
#![allow(unused)] fn main() { let mut s: usize = 0; for i in 0..100_000_000 { if i % 2 == 1 { s += i; } } }
Dieser Code hat tatsächlich überhaupt keine Laufzeit und wird sofort
ausgewertet. Der Compiler ist klug genug, das Ziel des Codes zu verstehen
und den endgültigen Wert der Variable s durch die gegebende Zahl zu
ersetzen, so dass alles zur Compilezeit berechnet werden kann. Dies zeigt
nun, dass kompilierte Sprachen von Methoden profitieren können,
über die interpretierte Sprachen aufgrund ihres
Ansatzes einfach nicht verfügen. Allerdings haben wir bereits gesehen,
dass kompilierte Sprachen in der Regel mehr Codezeilen und mehr Arbeit
erfordern. Außerdem gibt es bei kompilierten Sprachen in der Regel viele
weitere Konzepte zu lernen als bei interpretierten Sprachen.
Python kann sehr performant sein
Während dieser Veranstaltung werden wir oft Python-Bibliotheken wie NumPy oder SciPy für mathematische Algorithmen und insbesondere lineare Algebra verwenden. Diese Pakete bringen zwei wesentliche Vorteile. Einerseits ermöglichen sie die sehr einfache Verwendung komplizierter Algorithmen und andererseits sind diese Pakete in kompilierten Sprachen wie C oder Fortran geschrieben. Auf diese Weise können wir von den Leistungsvorteilen profitieren, ohne eine potenziell kompliziertere Sprache lernen zu müssen.
Erste Schritte
Python-Distribution
Wir empfehlen die Verwendung der Miniforge Distribution. Falls Sie bereits die Anaconda Distribution installiert haben, können Sie auch diese für den Kurs verwenden. Folgen Sie die untenstehenden Anweisungen, um Miniforge zu installieren. Sie sollten eine Python-Version von 3.9 oder höher verwenden.
Installation
- Laden Sie den Miniforge-Installer gemäß Ihrem Betriebssystem hier herunter.
- Führen Sie den Installer aus:
- Mac OS & Linux:
Öffnen Sie das Terminal, navigieren Sie zu Ihrem Downloads-Ordner und
rufen Sie
auf, wobeibash [Miniforge-Installer].sh[Miniforge-Installer]durch den Namen des Installers ersetzt werden sollte, z.B.Miniforge3-Linux-x86_64.shoderMiniforge3-MacOSX-arm64.sh. - Windows: Doppelklicken Sie auf den Installer im Explorer.
- Mac OS & Linux:
Öffnen Sie das Terminal, navigieren Sie zu Ihrem Downloads-Ordner und
rufen Sie
Python-Pakete
Während der Vorlesung werden wir mehrere Python-Pakete verwenden, die
bequem mit dem Paketmanager mamba installiert werden können, der mit
der Miniforge-Distribution installiert wird.
Einige der wichtigsten Pakete sind in der Tabelle unten aufgeführt.
Bitte installieren Sie diese Pakete, indem Sie die folgenden Befehle
in Ihrem Terminal ausführen.
Wenn Sie Windows verwenden, führen Sie die Befehle in der Miniforge Prompt aus, die mit Ihrer Miniforge-Installation geliefert wird.
| Paket | Befehl |
|---|---|
| NumPy | mamba install numpy |
| Matplotlib | mamba install -c conda-forge matplotlib |
| SciPy | mamba install -c conda-forge scipy |
| Jupyter | mamba install jupyter |
Jupyter Notebook
Wenn Sie ein Anfänger in Python sind, empfehlen wir die Verwendung von Jupyter Notebook zum Schreiben und Ausführen Ihres Python-Codes. Es handelt sich um ein blockweise ausführbares Dokument, das Code, Text und Grafiken enthalten kann. Um Ihre Python-Installation zu testen und sich mit Jupyter Notebook vertraut zu machen, laden Sie bitte die Datei Crashkurs: Jupyter Notebooks und Python herunter, die in WueCampus bereitgestellt ist. Sie können das Notebook öffnen indem Sie
jupyter notebook
in Ihrem Terminal eingeben. Anschließend öffnet sich ein neuer Tab in Ihrem Webbrowser, wo Sie zum Ordner navigieren können, in dem sich das Jupyter Notebook befindet. Öffnen Sie das Notebook und führen Sie alle Code-Zellen aus. Wenn Ihre Installation erfolgreich war, sollten Sie keinen Fehler erhalten. Zur Bedienung des Notebooks finden Sie viele weitere Tutorials im Internet, wie z.B. hier.
Empfehlungen für weitere integrierte Entwicklungsumgebungen (IDEs) oder Editoren
Wenn Sie bereits mit Python vertraut sind und eine erweiterte Entwicklungsumgebung nutzen möchten, sollten Sie eine IDE oder einen spezialisierten Editor verwenden. Obwohl es möglich ist, Python-Code mit einem herkömmlichen Texteditor zu schreiben, kann eine gute IDE Ihre Programmiererfahrung erheblich verbessern. Deshalb stellen wir Ihnen hier einige Empfehlungen für IDEs und spezialisierte Editoren neben Jupyter Notebook vor.
-
- voll ausgestattete Python-IDE mit Schwerpunkt auf wissenschaftlichr Entwicklung
- leichtgewichtig und einfach zu konfigurieren
-
- einer der meistgenutzten Editoren trotz seiner Leichtigkeit
- fast alle Funktionen einer IDE, obwohl es offiziell keine ist
-
- kommerzielle (für Studierende kostenlos) IDE mit vielen Funktionen
- für sehr große und komplexe Python-Projekte geeignet
- möglicherweise zu umständlich für kleine Projekte wie die in dieser Veranstaltung
-
Vim/NeoVim
- Kommandozeilen-Editor, der auf fast allen Unix-ähnlichen Computern vorinstalliert ist
- möglicherweise sehr unhandlich am Anfang aufgrund der vielen Tastenkombinationen und der mangelnden Anfängerfreundlichkeit im Vergleich zu einer typischen IDE oder Jupyter Notebooks
- extrem konfigurierbar
- mit einigem Aufwand zu einem sehr umfangreichen und komfortablen Editor anpassbar
- immer noch einer der meistgenutzten Editoren in der Softwareentwicklung
Bedienung dieser Webseite
Dieser Abschnitt gibt eine Einführung in die Bedienung dieses Vorlesungsskripts. Dieses ist in Kapitel organisiert, welche wiederum in Abschnitte unterteilt sind. Typischerweise ist jedes (Unter-)Kapitel in eine Reihe von Überschriften unterteilt.
Navigation
Es gibt mehrere Methoden, um durch die Kapitel eines Buches zu navigieren.
Die Seitenleiste auf der linken Seite bietet eine Liste aller Kapitel. Durch Klicken auf einen der Kapiteltitel wird die entsprechende Seite geladen.
Die Seitenleiste erscheint möglicherweise nicht automatisch, wenn das Fenster zu schmal ist, insbesondere auf mobilen Displays. In diesem Fall kann das Menüsymbol (drei horizontale Balken) oben links auf der Seite gedrückt werden, um die Seitenleiste zu öffnen und zu schließen.
Die Pfeiltasten links und rechts neben der Seite können verwendet werden, um zum vorherigen oder nächsten Kapitel zu navigieren.
Die Pfeiltasten auf der Tastatur können verwendet werden, um zum vorherigen oder nächsten Kapitel zu navigieren.
Obere Menüleiste
Die Menüleiste oben auf der Seite bietet einige Symbole zur Interaktion mit dem Vorlesungsskript.
| Symbol | Beschreibung |
|---|---|
| Öffnet und schließt die Seitenleiste. | |
| Öffnet eine Liste zur Auswahl eines anderen Farbthemas. | |
| Öffnet eine Suchleiste für die Suche im Skript. | |
| Fordert den Webbrowser auf, das Skript zu drucken. | |
| Öffnet den WueCampus-Kursraum zu dieser Veranstaltung. |
Mit einem Klick auf die Menüleiste scrollt die Seite nach oben.
Suche
Dieses Vorlesungsskript verfügt über ein eingebautes Suchsystem.
Durch Drücken des Suchsymbols () in der
Menüleiste oder Drücken der Taste S auf der Tastatur wird ein Eingabefeld
zum Eingeben von Suchbegriffen geöffnet.
Das Eingeben von Begriffen zeigt passende Kapitel und Abschnitte in
Echtzeit an.
Durch Klicken auf eines der Ergebnisse wird zu diesem Abschnitt gesprungen. Die Pfeil-nach-oben- und Pfeil-nach-unten-Tasten können verwendet werden, um die Ergebnisse zu navigieren, und Enter öffnet den markierten Abschnitt.
Nach dem Laden eines Suchergebnisses werden die passenden Suchbegriffe
im Text hervorgehoben.
Durch Klicken auf ein hervorgehobenes Wort oder Drücken der Esc-Taste
werden die Hervorhebungen entfernt.
Codeblöcke
Codeblöcke besitzen ein Kopieren-Symbol , das den Codeblock in die lokale Zwischenablage kopiert.
Hier ein Beispiel:
print("Hello, World!")
Wir verwenden häufig die assert-Anweisung in Codeblöcken, um Ihnen den
Wert einer Variablen anzuzeigen. Da die Codeblöcke in diesem Dokument nicht
interaktiv sind (Sie können sie nicht einfach in Ihrem Browser ausführen),
ist es nicht möglich, den Wert der Variablen auf dem Bildschirm auszugeben.
Deshalb stellen wir für Sie sicher, dass alle Codeblöcke in diesem
Vorlesungsskript fehlerfrei laufen. Der folgende
Codeblock zeigt Ihnen beispielsweise an, dass der Variable „a“ der Wert 2
zugewiesen wurde:
a = 2
assert a == 2
Regressionsanalyse
Eine sehr häufige Aufgabe der Datenanalyse besteht darin, ein Modell an einen Datensatz anzupassen. Dies nennt man Regressionsanalyse, ein grundlegendes statistisches Werkzeug in unzähligen wissenschaftlichen Untersuchungen, auch in der Chemie, welches die Beziehungen zwischen Variablen aufdeckt.
Betrachten Sie die folgenden praktischen Beispiele: Anwendung des Lambert- Beerschen Gesetzes zur Quantifizierung der Konzentration einer Lösung basierend auf Lichtabsorption, Bestimmung des Einflusses der Temperatur auf Reaktionsgeschwindigkeiten, Herausfinden der Beziehung zwischen Substratkonzentration und Enzymaktivität. Jedes dieser Beispiele zeigt, wie die Regressionsanalyse dabei hilft, experimentelle Daten zu modellieren, Beziehungen zwischen Variablen zu extrahieren und Vorhersagen zu treffen.
In diesem Kapitel werden wir uns mit den Grundlagen der Regressionsanalyse befassen, angefangen bei der einfachen linearen Regression bis hin zu komplexeren Modellen. Wir werden uns auch mit den zugrundeliegenden Optimierungsalgorithmen befassen, die zum Fitten dieser Modelle an die Daten verwendet werden. Darüberhinaus werden wir die Bedeutung der Modellevaluation und der Regularisierungstechniken zur Vermeidung vom Überfitten diskutieren.
Methode der kleinsten Quadrate
Das Ziel aller Regressionsprobleme besteht darin, ein Modell zu finden, welches am besten zu den Daten passt und hoffentlich zur Vorhersage neuer Datenpunkte verwendet werden kann. Einfachheitshalber gehen wir davon aus, dass unsere Daten nur eine unabhängige Variable () und eine abhängige Variable () haben. Eine Verallgemeinerung auf mehrere unabhängige Variablen ist simpel und wird in einem späteren Kapitel diskutiert. Darüber hinaus gehen wir davon aus, dass die Daten reellwertig sind. Für insgesamt Datenpunkte schreiben wir d.h. und sind -dimensionale Vektoren mit reellwertigen Komponenten und .
Wir können die Beziehung zwischen und als eine Funktion betrachten. Unter idealisierten Bedingungen haben wir wobei die Funktion die wahre Beziehung zwischen den Datenpunkten und beschreibt, während den zufälligen statistischen Fehler darstellt.
In der realen Welt ist die Beziehung jedoch oft unbekannt. Daher müssen wir ein Modell verwenden, um sie abzuschätzen. In diesem Fall schreiben wir wobei der Abschätzer des Modells von ist, parametrisiert durch mit Parametern im Modell. Der Fehlerterm ist nun eine Kombination aus dem statistischen Fehler und dem unmodellierten Teil der echten Beziehung. Das Ziel der Regressionsanalyse besteht darin, die Parameter zu finden, sodass das Modell die Daten am besten wiedergibt.
Aber was bedeutet “am besten”? Ein üblicher Ansatz ist die Methode der kleinsten Quadrate, die darauf abzielt, die Summe der quadratischen Fehler zu minimieren, d.h. die Verlustfunktion der kleinsten Quadrate zu minimieren. Mathematisch ausgedrückt wollen wir die Parameter durch Lösen des Optimierungsproblems finden. Wenn wir den Vektor der vorhergesagten Werte als schreiben, können wir Gl. (1.4) als formulieren, wobei die euklidische Norm oder die -Norm eines Vektors bezeichnet, definiert als
Die Methode der kleinsten Quadrate ist eine beliebte Wahl für die Regressionsanalyse, da sie geschlossene Lösungen für einige einfache, aber wichtige Modelle bietet und mit Methoden der numerischen linearen Algebra für komplexere Modelle effizient gelöst werden kann. Es weist jedoch einige Nachteile auf, z.B. die Empfindlichkeit gegenüber Ausreißern und die Anfälligkeit für Überanpassungen. Eine Alternative zur Methode der kleinsten Quadrate ist die Methode der kleinsten absoluten Abweichungen, welche die -Norm anstelle der -Norm verwendet. Diese ist für einen Vektor definiert als
Das Optimierungsproblem wird dann formuliert als
Es gibt viele andere Verlustfunktionen, die für die Regression verwendet werden können, z.B. die Huber-Verlustfunktion, die log-cosh-Verlustfunktion, die Quantilverlustfunktion usw. Obwohl die Methode der kleinsten Quadrate in vielen Fällen gut funktioniert, ist es wichtig, sich der Grenzen der Methode bewusst zu sein und gegebenenfalls andere Verlustfunktionen heranzuziehen.
Lineare Regression
Eines der einfachsten Modell der Regressionsanalyse ist das lineare Modell, wobei der Abschätzer durch eine lineare Funktion der Form mit den skalaren Parametern und gegeben ist. Die Regressionsanalyse mit dem linearen Modell wird als lineare Regression bezeichnet.
Theoretische Grundlagen
Setzen wir nun das lineare Modell nach Gl. (1.7) in die Verlustfunktion der kleinsten Quadrate gemäß Gl. (1.3) ein, erhalten wir
Da unser Ziel darin besteht, die Parameter zu finden, welche die Verlustfunktion minimieren (vgl. Gl. (1.4)), müssen zumindest die notwendigen Bedingungen dafür erfüllt werden, d.h. die partiellen Ableitungen von nach und müssen verschwinden:
Nach einer etwas länglichen aber einfachen
Herleitung
Als erstes berechnen wir die partiellen Ableitungen von nach und . Da das Differenzieren linear ist, können wir die Summe in Gl. (1.8) über die einzelnen Terme aufteilen: Genau so verfahren wir mit der Ableitung nach und erhalten
Setzen wir nun die Ableitungen gleich null, erhalten wir die notwendigen Bedingungen für ein Minimum: Der Faktor spielt keine Rolle, da der Ausdruck gleich null gesetzt wird. Daher können wir ihn im Folgenden weglassen.
Durch einfaches Umstellen können wir die obigen Gleichungen als ein lineares Gleichungssystem in und schreiben: Da die Parameter und unabhängig von den Datenindizes sind, können wir sie aus den Summen herausziehen und erhalten wobei wir genutzt haben. Dieses Gleichungssystem ist äquivalent zu der Matrixgleichung in Gl. (1.9).
erhalten wir die Matrixgleichung für die Parameter und : Die Lösung dieses Gleichungssystems liefert uns die optimalen Parameter und für das lineare Modell.
Die (zumindest formale) Lösung des Gleichungssystems (1.9) ist , mit der Inversen der Matrix . Eine Matrix ist genau dann invertierbar, wenn ihre Determinante ungleich null ist. Die Determinante der Systemmatrix ist wobei wir die Abkürzung für den Mittelwert der eingeführt haben. Hätten wir nur einen einzigen Datenpunkt, wäre der Mittelwert gleich dem einzigen Datenpunkt und die Determinante der Systemmatrix gleich null. Das bedeutet, dass die Matrix nicht invertierbar ist und wir keine eindeutige Lösung für das lineare Modell erhalten. Erst ab zwei verschiedenen Datenpunkten ist das Gleichungssystem (1.9) eindeutig lösbar.
Nun wollen wir mit Hilfe der Gl. (1.9) die lineare Regression für ein einfaches Beispiel implementieren.
Implementierung
Betrachten wir die folgenden Messdaten1 der Lambert-Beer-Beziehung für Methylenblau in Wasser bei verschiedenen Konzentrationen und den zugehörigen Absorbanzen , gemessen bei mit einer Schichtdicke von 1 cm:
| / µM | / µM | ||
|---|---|---|---|
| 2.125 | 0.0572 | 23.38 | 0.8242 |
| 4.250 | 0.1391 | 25.50 | 0.9130 |
| 6.375 | 0.2049 | 27.63 | 1.0043 |
| 8.500 | 0.2754 | 29.75 | 1.0809 |
| 10.63 | 0.3420 | 31.88 | 1.1511 |
| 12.75 | 0.4139 | 34.00 | 1.2483 |
| 14.88 | 0.4956 | 36.13 | 1.3373 |
| 17.00 | 0.5815 | 38.25 | 1.4027 |
| 19.13 | 0.6806 | 40.38 | 1.4927 |
| 21.25 | 0.7481 | 42.50 | 1.5853 |
Bevor wir fortfahren können, müssen wir die Daten irgendwie in Python importieren. Der einfachste Weg für so einen kleinen Datensatz ist die manuelle Eingabe.
concentrations = [
2.125, 4.250, 6.375, 8.500, 10.63, 12.75, 14.88, 17.00, 19.13, 21.25,
23.38, 25.50, 27.63, 29.75, 31.88, 34.00, 36.13, 38.25, 40.38, 42.50,
]
absorbances = [
0.0572, 0.1391, 0.2049, 0.2754, 0.3420,
0.4139, 0.4956, 0.5815, 0.6806, 0.7481,
0.8242, 0.9130, 1.0043, 1.0809, 1.1511,
1.2483, 1.3373, 1.4027, 1.4927, 1.5853,
]
Hier haben wir die Daten in den Variablen concentrations und absorbances
der Typ List (Liste) definiert. Das erkennt man an der Verwendung der eckigen
Klammern [] und dem Komma , zwischen den einzelnen Werten. Mit dem
Gleichheitszeichen = weisen wir den Variablen die Werte zu.
Obwohl Python standardmäßig schon einige mathematische Funktionen für
Listen bereitstellt, hat das Paket numpy noch mehr sehr nützliche
Operationen für solchen Datenstrukturen. Daher importieren wir numpy
und konvertieren die Listen in den Datentyp numpy-array.
import numpy as np
concentrations = np.array(concentrations)
absorbances = np.array(absorbances)
Da numpy etwas länger zu schreiben ist, verwenden wir den Alias np,
definiert durch import numpy as np. Ab sofort können wir Inhalte
dieses Pakets mit np ansprechen. In den folgenden Zeilen verwenden
wir die Funktion np.array um die Listen in Arrays zu konvertieren.
Hier wird es deutlich, dass das Gleichheitszeichen = beim Programmieren
eine andere Bedeutung hat als in der Mathematik. Während in der Mathematik
das Gleichheitszeichen eine Äquivalenz zwischen zwei Seiten ausdrückt,
stellt dieses in der Programmierung eine Zuweisung dar. Deshalb ist es
hier möglich, die gleiche Variablename für die Arrays zu verwenden, da
die rechte Seite des Gleichheitszeichens zuerst ausgewertet wird und dann
der Wert der rechten Seite der Variablen auf der linken Seite zugewiesen
wird.
Als nächstes wollen wir die Elemente von und in
Gl. (1.9) berechnen. Dafür stellen wir
erstmal sicher, dass unsere Datenarrays gleich lang sind. Danach können wir
die Elemente mit Hilfe der np.sum-Funktion berechnen.
# make sure the number of data points is the same
assert len(concentrations) == len(absorbances)
number_of_points = len(concentrations)
sum_x = np.sum(concentrations)
sum_x_sq = np.sum(concentrations**2)
sum_y = np.sum(absorbances)
sum_xy = np.sum(concentrations * absorbances)
Hier haben wir die
built-in Funktion
len verwendet, um die Länge der Arrays zu bestimmen. Die Funktion
np.sum berechnet die Summe aller Elemente in einem Array. Mit dem
Stern * ist die Multiplikation gemeint. Die Verwendung von * zwischen
zwei Arrays führt zu einer elementweisen Multiplikation. Der
Doppelstern ** bedeutet in Python die Potenzierung. Hier wird also
das Array concentrations elementweise quadriert.
Info für Fortgeschrittene
Die Operatoren * und ** können auch als unäre Operatoren, also
Operatoren für nur ein Argument, im Gegensatz zu binären Operatoren
wie die Multiplikation, die zwei Argumente benötigt, verwendet werden.
Als unärer Operator haben sie dann eine andere Bedeutung. Die Interessierten,
die schon etwas Erfahrung in der Programmierung haben, können sich die
details z.B. hier
nachlesen.
Nun können wir die Systemmatrix und den Vektor zusammenstellen:
a_arr = np.array([
[number_of_points, sum_x],
[sum_x, sum_x_sq],
])
b_arr = np.array([sum_y, sum_xy])
Beachte Sie, dass für die Erstellung einer Matrix (bzw. eines sog. 2D-Arrays) eine Liste von Listen verwendet wird. Die innere Liste entspricht einer Zeile der Matrix. Die äußere Liste enthält die Zeilen der Matrix. Jetzt können wir endlich das Gleichungssystem (1.9) lösen, um die optimalen Parameter und zu erhalten:
beta = np.linalg.solve(a_arr, b_arr)
print(beta)
Die Funktion
np.linalg.solve
löst (numerisch) ein lineares Gleichungssystem. Mit der print-Funktion
geben wir die Lösung anschließend aus.
Eine analytische Lösung des Gleichungssystems (1.9) ist möglich, da die Systemmatrix nur groß ist und damit analytisch invertierbar.
Wenn man schon weiß, welche Werte und haben sollen und nur den Algorithmus testen möchte, kann man die Verifikation z.B. wie folgt durchführen:
beta0 = beta[0]
beta1 = beta[1]
assert np.isclose(beta0, -0.04907034)
assert np.isclose(beta1, 0.03800109)
Hier haben wir zuerst den Array beta mit eckigen Klammern [] indexiert.
Beachten Sie, dass die Indizierung in Python bei 0 beginnt. Das bedeutet, dass
dem 0-ten Eintrag und den ersten Eintrag des Arrays beta
entspricht. Anschließend haben wir die Werte von und
mit Referenzwerten verglichen.
Es wurden nicht die exakten Werte mit == verglichen, da einerseits
float-Zahlen (Gleitkommazahlen)
nicht exakt dargestellt werden können und andererseits die numerische
Lösung des Gleichungssystems nicht exakt sein muss. Deshalb haben wir
die Funktion
np.isclose
verwendet, die die Werte mit einer (in diesem Fall voreingestellten)
Toleranz vergleicht.
Das Lambert-Beer-Gesetz besagt, dass die Absorption linear von der Konzentration abhängt mit der Proportionalitätskonstante , also Das molare Extinktionskoeffizient kann also aus dem linearen Parameter berechnet werden als
Jetzt haben wir die optimalen Parameter und für den obigen Datensatz berechnet. Wie soll man wissen, wie gut die lineare Regression ist? Dafür gibt es verschiedene mathematische Gütekriterien, die wir hier aber erstmal nicht betrachten. Stattdessen wollen wir die optimalen Parameter grafisch darstellen und die Qualität der Regression visuell beurteilen.
Visualisierung
Zur Visualisierung der optimalen linearen Regression verwenden wir das
Python-Paket matplotlib. Hier importieren wir das Untermodul pyplot
mit dem Alias plt:
import matplotlib.pyplot as plt
Für die graphische Darstellung mit matplotlib benötigen wir immer
die Objekte Figure und Axes. Diese können wir mit der Funktion
plt.subplots
erstellen:
fig, ax = plt.subplots(figsize=(8, 6))
Wir haben hier als Argument figsize=(8, 6) übergeben, welches die Größe
der Abbildung in Zoll angibt. Die Größe der Abbildung ist aber nicht
in Stein gemeißelt und kann je nach Bedarf angepasst werden.
Nun wollen wir die Messdaten als Punkte und die lineare Regression als
Linie in das Diagramm eintragen. Dafür verwenden wir die Funktion
ax.plot:
ax.plot(concentrations, absorbances, 'o', label='data')
ax.plot(concentrations, beta0 + beta1 * concentrations, label='fit')
Für die Messdaten haben wir das Argument o verwendet, damit diese als
Punkte dargestellt werden. Andere
Marker
wie z.B. s für Quadrate oder x für Kreuze können auch verwendet werden.
Ohne Angabe dieses Arguments werden die Daten als Linie dargestellt.
Bei beiden Aufrufen von ax.plot haben wir die Argumente label verwendet.
Diese dienen dazu, die Linien in der Legende zu benennen.
Für die Vollständigkeit wollen wir auch die Achsen noch beschriften und eine Legende hinzufügen:
ax.set_xlabel('concentration / µM')
ax.set_ylabel('absorbance')
# automatically create a legend
ax.legend()
Die Funktionen ax.set_xlabel und ax.set_ylabel akzeptieren einen
str (String, Zeichenkette) als Argument, der als Achsenbeschriftung
dient. Die Funktion ax.legend fügt eine Legende hinzu. Ruft man sie
ohne Argumente auf, wird die Legende aus den label-Argumenten der
ax.plot-Funktionen automatisch generiert.
Nachdem alle Elemente des Diagramms eingefügt wurden, können wir die
Abbildung mit der Funktion plt.show anzeigen lassen:
plt.show()
Wenn das Programm erfolgreich ausgeführt wird, sollte ein Diagramm
wie das Folgende erscheinen:
Dieses Diagramm zeigt uns die Ergebnisse der linearen Regression, auch wenn
man über die ästhetische Gestaltung des Diagramms streiten kann. Im Laufe
dieses Kurses werden wir noch mehr Funktionalitäten von matplotlib
kennenlernen, die uns helfen, Elemente des Diagramms dem persönlichen
Geschmack entsprechend zu gestalten.
Auf den ersten Blick scheint die Regressionsgerade sehr gut zu den Messdaten zu passen. Um den Unterschied zwischen den Messdaten und der Regressionsgerade deutlicher zu machen, können wir die Residuen, also die Differenz zwischen den Messdaten und der Regressionsgerade, in einem weiteren Diagramm darstellen.
residuals = absorbances - (beta0 + beta1 * concentrations)
fig2, ax2 = plt.subplots(figsize=(8, 6))
ax2.bar(concentrations, residuals)
ax2.set_xlabel('concentration / µM')
ax2.set_ylabel('absorbance residuals')
plt.show()
Nach dem Berechnen und Speichern der Residuen in der Variablen residuals
wurden ein weiteres Figure- sowie Axes-Objekt erstellt. Die Residuen
wurden in ein Balkendiagramm dargestellt, welches mit der Methode ax2.bar
erstellt werden kann. Das Diagramm sieht dann wie folgt aus:
Jetzt kann man erkennen, dass bei niedrigen und hohen Konzentrationen die Abweichung positiv ist, während sie bei mittleren Konzentrationen negativ ist. Das könnte auf eine leichte positive Krümmung der Daten hinweisen, die mit dem linearen Modell nicht erfasst wird. Eine Zuordnung dieses Verhaltens zu einer systematischen oder zufälligen Abweichung bedarf allerdings in der Regel einer tieferen Analyse. Tatächlich liegt hier in den Daten eine leichte Nichtlinearität vor, welches die Abweichung vom Lambert-Beer-Gesetz v.a. bei hohen Konzentrationen demonstriert. Wer sich für die genaue Erklärung dieser Abweichung von Methylenblau in Wasser interessiert, kann sich z.B. die Publikation A. Fernández-Pérez, T. Valdés-Solís, G. Marbán, Dyes and Pigments 2019, 161, 448–456 durchlesen. Ob die lineare Regression in diesem Fall sinnvoll ist, hängt von der gewünschten Genauigkeit der Modellierung ab.
Hier haben wir die import Befehle in den jeweiligen Abschnitten platziert,
um die Abhängigkeiten der verschiedenen Teile des Skripts zu verdeutlichen.
Der resultierende Code kann ohne Fehler interpretiert werden.
Allerdings ist es in Python üblich, die Importe am Anfang des Skripts zu
platzieren, also
import numpy as np
import matplotlib.pyplot as plt
# Rest des Skripts
Die Autoren bedanken sich bei Dr. Hans-Christian Schmitt für die Bereitstellung der Daten.
Übung
Aufgabe 1.1: Lineare und quadratische Regression
In dem obigen Beispiel haben wir die numpy Funktion np.linalg.solve verwendet, um die Lösung des
Gleichungssystems der linearen Regression numerisch zu berechnen. In diesem Zusammenhang bedeutet
dies, dass der Computer einer Reihe von Rechenschritten und Algorithmen folgt, um die approximative
Lösung des Gleichungssystems zu finden. Für das Gleichungssystem der linearen Regression gibt es jedoch
auch eine analytische Lösung, die direkt berechnet werden kann.
(a) Analytische Lösung der linearen Regression herleiten
Zeigen Sie, dass die Lösung des Gleichungssystems in Matrixform gegeben ist durch:
Lösen Sie dazu zunächst die erste Gleichung des Systems nach auf und setzen Sie das Ergebnis in die zweite Gleichung ein. Verwenden Sie außerdem die Definitionen der Mittelwerte und .
(b) Implementieren der analytischen Lösung für Messdaten von Methylenblau
Nutzen Sie die analytische Lösung, um die Parameter der linearen Regression für die Messdaten von Methylenblau
explizit zu berechnen. Vergleichen Sie die Ergebnisse mit den Ergebnissen, die Sie mit np.linalg.solve
erhalten haben.
(c) Matrixgleichung der quadratischen Regression herleiten
Die quadratische Regression ist eine Erweiterung der linearen Regression, bei der die abhängige Variable durch ein Polynom zweiten Grades in der unabhängigen Variable angenähert wird. Die allgemeine Form der quadratischen Regression ist gegeben durch:
In Analogie zur linearen Regression können wir die quadratische Regression als ein lineares Modell in den Parametern auffassen. Zeigen Sie, dass dieses Modell durch die folgende Matrixgleichung beschrieben wird:
Setzen Sie dazu die quadratische Funktion in die allgemeine Form der Verlustfunktion der Methode der kleinsten Quadrate ein und bilden Sie die Ableitungen nach den gesuchten Parametern.
(d) Quadratische Regression implementieren und an Methylenblau-Daten anwenden
Fahren Sie nun fort wie für die lineare Regression, indem Sie das Gleichungssystem der quadratischen
Regression aus Teilaufgabe (c) für die Methylenblau-Daten numerisch lösen.
Konstruieren Sie dazu zunächst die benötigte Matrix, bzw. den Vektor in Form von Arrays, und verwenden
Sie die Funktion np.linalg.solve. Plotten Sie anschließend die quadratische Regression zusammen
mit den Datenpunkten. Plotten Sie ebenfalls die Resiuduen und vergleichen Sie die Ergebnisse mit der
linearen Regression.
Numerische Optimierung
Die numerische Optimierung bietet uns die Möglichkeit, komplexe Funktionen, wie die Verlustfunktionen der kleinsten Quadrate (Gl. (1.3)), aber auch Funktionen im anderen Kontext, wie z.B. die Energie eines Moleküls, zu minimieren oder maximieren. Da die Maximierung einer Funktion äquivalent zur Minimierung von ist, werden wir im Folgenden nur noch vom Minimieren sprechen. Die Fähigkeit, Optimierungsprobleme anzugehen, für die keine geschlossenen Lösungsformeln existieren, oder die Auswertung des analytischen Ausdrucks zu aufwendig ist, erweitert unser Werkzeugset in der Datenanalyse signifikant. Insbesondere erlaubt sie es, Lösungen für Modelle zu finden, die durch Nichtlinearitäten, hohe Dimensionalitäten oder ungewöhnliche Datenverteilungen charakterisiert sind.
Theoretische Grundlagen
Ein besonders zugänglicher und grundlegender Ansatz für die numerische Optimierung ist das Gradientenverfahren (engl. Gradient Descent). Dies ist ein iteratives Verfahren, welches von einem gegebenen Startpunkt ausgeht und in jedem Schritt der Richtung des steilsten Abstiegs der Funktion folgt. Mathematisch formuliert heißt das wobei der Schätzwert des Minimums im -ten Schritt ist. Das hochgestellte hat hier nichts mit Potenzierung zu tun, sondern ist lediglich eine Notation, um die Verwechselung mit der -ten Komponente des Vektors, die hier tiefgestellt wird, zu vermeiden. Der Gradient der Objektivfunktion bei kann dann als notiert werden. Die Propotionialitätskonstante wird als Schrittweite oder learning rate bezeichnet. Das Verfahren wird solange wiederholt, bis eine oder mehrere Abbruchbedingungen erfüllt sind. Typische Abbruchbedingungen für iterative Optimierungsverfahren sind:
- Die Änderung des Funktionswertes ist kleiner als ein Schwellenwert
- Die Änderung des Schätzwertes ist kleiner als ein Schwellenwert
- Eine maximale Anzahl an Iterationen ist erreicht
- Die Norm des Gradienten ist kleiner als ein Schwellenwert.
Die Schrittweite ist der einzige und zugleich ein wichtiger Parameter des Gradientenverfahrens, da sie die Konvergenzgeschwindigkeit und die Stabilität des Verfahrens beeinflusst. Ein zu kleiner Wert für kann dazu führen, dass das Verfahren sehr langsam konvergiert, während ein zu großer Wert dazu führen kann, dass das Verfahren divergiert.
Damit wir das Gradientenverfahren nutzen können, müssen wir Zugang zum Gradienten der Objektivfunktion haben. Liegt dieser nicht analytisch vor, muss eine numerische Approximation verwendet werden. Ein einfacher Ansatz liefert die Methode der Finite Differenz . Hierbei wird die tangente der partiellen Ableitung durch die Sekante ersetzt, was zu einer Approximation der Form führt. Zudem ist der -te Einheitsvektor und ein kleiner Wert, der die Schrittweite der Approximation bestimmt. Genau genommen stellt Gl. (1.11) die zentrale finite Differenz 2. Ordnung dar. Es gibt auch einseitige Approximationen und Approximationen höherer Ordnung, die wir hier aber nicht weiter betrachten.
Implementierung
Finite Differenz
Als erstes implementieren wir die finite Differenz. Da wir mehrmals Ableitungen berechnen werden müssen, ist es sinnvoll, eine Funktion zu implementieren. Funktionen im Programmierkontext sind ähnlich zu mathematischen Funktionen, die eine Eingabe in eine Ausgabe umwandeln. Sie können aber noch einiges mehr.
Wir importieren zuerst numpy und die Objekte Callable und Any
aus dem Modul typing:
import numpy as np
from typing import Callable, Any
Dann definieren wir die Funktion finite_difference, die den Gradienten einer
Funktion func an der Stelle x0 berechnet.
def finite_difference(
func: Callable[[np.ndarray, Any], float],
x0: np.ndarray,
h: float = 1e-5,
args: tuple = (),
) -> np.ndarray:
n: int = len(x0)
grad: np.ndarray = np.zeros(n)
for i in range(0, n):
e: np.ndarray = np.zeros(n)
e[i] = 1
grad[i] = (
func(x0 + h * e, *args) - func(x0 - h * e, *args)
) / (2 * h)
return grad
Der erste Block der Funktion ist die Signatur, die angibt, welche
Argumente die Funktion erwartet und welchen Typ die Rückgabe hat. Hierfür
wurden die Objekte Callable und Any benutzt. Obwohl die explizite Angabe
der Datentypen in Python optional und immer noch selten ist, ist es nach
Meinung der Autoren eine gute Praxis. Näheres dazu finden Sie in dem
folgenden
Infokasten
Eine Analogie zur Signatur in der Mathematik ist die Definitionsmenge und der Wertebereich einer Funktion, die einschränken, welche Argumente akzeptiert werden und welche Werte zurückgegeben werden. So funktioniert auch die Signatur in Computerprogrammen.
In statisch typisierten (engl. statically typed) Programmiersprachen, wie C++, Java oder Rust, müssen Funktionen explizite Signaturen haben. Diese Angabe wird vom Compiler verwendet, um sicherzustellen, dass die Funktion korrekt verwendet wird und ggf. Optimierungen durchzuführen.
Als Beispiel dient hier eine naive Implementierung der Funktion powi
in Rust, die die ganzzahlige Potenz einer Gleitkommazahl berechnet:
#![allow(unused)] fn main() { fn powi(x: f64, n: i32) -> f64 { let mut result: f64 = 1.0; for _ in 0..n { result *= x; } result } }
Während die Details dieser Funktion uns nicht interessieren, ist die
erste Zeile des Codeblocks fn powi(x: f64, n: i32) -> f64 wichtig,
da sie die Signatur der Funktion enthält. Man erkennt, dass die Funktion
powi zwei Argumente vom Typ f64 und i32 erwartet und einen Wert vom
Typ f64 zurückgibt.
Eine übliche Implementierung dieser Funktion in Python könnte so aussehen:
def powi(x, n):
result = 1.0
for _ in range(n):
result *= x
return result
Hier gibt es keine explizite Signatur, da Python eine dynamisch typisierte (engl. dynamically typed) Programmiersprache ist. An dieser Funktion erkennt man auf dem ersten Blick nicht, ob sie für Ganz- oder Gleitkommazahlen gedacht ist. Um diese Funktion also korrekt zu verwenden, muss man sich mit der genauen Implementierung auseinandersetzen, während man durch die Signatur sofort weiß, welche Argumente erwartet werden.
Deshalb empfehlen die Autoren, obwohl der Python-Interpreter keine explizite Typisierung erfordert, die Verwendung von Typen in der Signatur von Funktionen, um die Lesbarkeit und Wartbarkeit des Codes zu verbessern.
Die Signatur der Funktion finite_difference besagt, dass sie eine
Funktion func erwartet, die als Argument einen np.ndarray und
irgendwas (Any) akzeptiert und einen Float zurückgibt.
Die weiteren Argumente sind der Punkt x0 vom Typ np.ndarray, an dem
der Gradient berechnet werden soll, die Schrittweite h vom Typ Float
und ein Tuple (engl. tuple) args, das zusätzliche Argumente für die
Funktion func liefern kann. Außerdem haben wir in der Signatur
voreingestellte (engl. default) Werte für h und args definiert.
Das bedeutet, dass die Werte h = 1e-5 und args = ()
verwendet werden, wenn die Funktion finite_difference ohne die
Argumente h und args aufgerufen wird.
In der Funktion wird zuerst die Dimension des Punktes x0 bestimmt und
in der Integer n gespeichert. Der Gradient einer reellwertigen
Funktion mit Variablen ist ein Vektor der Länge . Daher wird
die Variable grad als ein np.ndarray der Länge n aus Nullen
durch die Funktion
np.zeros
initialisiert.
Weil wir Gl. (1.11) auf alle
Komponenten des Punktes anwenden müssen, ist die Nutzung einer
Schleife (engl. loop) sinnvoll. Hier benutzen wir eine
for-Schleife,
die über alle Indizes i von 0 bis n - 1 iteriert. Beachten Sie, dass
Python-Indizes bei 0 beginnen und die built-in Funktion
range
den Endwert n nicht einschließt.
In jeder Iteration der Schleife definieren wir zuerst den Einheitsvektor
, indem wir zuerst ein Null-Array der Länge n mit
np.zeros(n) erstellen und dann die -te Komponente auf 1 setzen.
Danach können wir den i-ten Eintrag des Gradientenarrays grad gemäß
Gl. (1.11) berechnen. Der einzige
Unterschied zwischen unserem Code und dieser Gleichung ist, dass wir das
Zusatzargument args an die Funktion func übergeben, wobei
ein Stern * vor dem Argument args steht. Die Verwendung
von * wirkt an dieser Stelle als einen unären Operator,
der ein Objekt in seine Bestandteile entpackt
(engl. unpacking).
Das bedeutet, dass die Funktion func nach dem ersten Argument
die weitere Argumente im Tuple args einzelnd akzeptiert.
Nach der letzten Iteration geben wir den Gradienten in Form der Variable grad
zurück.
Objektivfunktion
Als nächstes implementieren wir die Objektivfunktion, deren Wert wir
minimieren wollen. Gemäß Gl. (1.8)
können wir die Funktion objective_function folgendermaßen definieren:
def objective_function(
beta: np.ndarray,
*args: np.ndarray,
) -> float:
concentrations: np.ndarray = args[0]
absorbances: np.ndarray = args[1]
return np.sum((absorbances - (beta[0] + beta[1] * concentrations))**2)
Die Signatur der Funktion objective_function folgt der Typdefinition
des Arguments func in der Funktion finite_difference
(Callable[[np.ndarray, Any], float]):
- Das Argument
betaist vom Typ np.ndarray, - das Argument
argsist vom Typ np.ndarray, also auchAnyund - die Funktion gibt einen Float zurück.
Hier sehen wir wieder die Verwendung von * vor dem Argument args.
In diesem Fall heißt das, dass die Funktion objective_function beliebig
viele weitere Argumente nach beta akzeptiert.
Die Funktion objective_function definiert zuerst die Arrays
concenctrations und absorbances aus dem Argument args
und gibt anschließend den Wert der Verlustfunktion der kleinsten
Quadrate gemäß Gl. (1.8) zurück.
Hier gibt es erneut keine große Unterschiede zwischen der mathematischen
Formulierung und der programmatischen Implementierung.
Mit Hilfe der Funktionen finite_difference können wir die Funktion
objective_function_gradient implementieren, die den Gradienten der
Objektivfunktion berechnet:
def objective_function_gradient(
beta: np.ndarray,
*args: np.ndarray,
) -> np.ndarray:
concentrations: np.ndarray = args[0]
absorbances: np.ndarray = args[1]
grad: np.ndarray = finite_difference(
objective_function,
beta,
args=(concentrations, absorbances),
)
return grad
Die Signatur dieser Funktion ist ähnlich zu der der Funktion
objective_function; lediglich der Rückgabetyp ist ein np.ndarray.
Erinnern Sie sich daran, dass, da die Objektivfunktion einen
-dimensionalen Vektor als Argument hat, der Gradient
ebenfalls ein -dimensionaler Vektor ist.
Nach der Definition der Arrays concenctrations und absorbances
berechnen wir den Gradienten der Objektivfunktion einfach durch
Aufrufen der Funktion finite_difference. Der Rückgabewert wird in der
Variable grad gespeichert und zurückgegeben.
Gradientenverfahren
Anschließend implementieren wir das Gradientenverfahren.
Aus Gl. (1.10) folgt, dass wir zum Starten des Verfahrens
den Gradienten der Objektivfunktion func_grad, den Startpunkt x0
und die Schrittweite alpha benötigen. Als Abbruchbedingung verwenden
wir eine Kombination aus der maximalen Anzahl an Iterationen und der Norm
des Gradienten. Das führt zu den zusätzlichen Argumenten max_iter und
max_norm. Außerdem brauchen wir noch das Argument args, das die
weiteren Argumente für die Funktion func_grad enthält.
def gradient_descent(
func_grad: Callable[[np.ndarray, Any], np.ndarray],
x0: np.ndarray,
alpha: float = 0.001,
max_norm: float = 1e-6,
max_iter: int = 10000,
args: tuple = (),
) -> tuple[np.ndarray, int]:
x: np.ndarray = np.copy(x0)
for niter in range(0, max_iter):
grad: np.ndarray = func_grad(x, *args)
x = x - alpha * grad
if np.linalg.norm(grad) < max_norm:
break
if niter == max_iter - 1:
print('Warning: Maximum iterations reached. '
'Result may not be reliable.')
return x, niter
Zuerst kopieren wir explizit den Array x0 mit der Funktion
np.copy und speichern die Kopie in der Variable x. Das ist notwendig,
da Python standardmäßig Arrays nicht kopiert, sondern nur Referenzen
auf sie speichert. Das bedeutet, dass, wenn wir x0 ändern, auch x
geändert wird. Um das zu vermeiden, kopieren wir den Array explizit.
Danach verwenden
wir eine for-Schleife, die die Variable niter von 0 bis max_iter - 1
iteriert. In jeder Iteration berechnen wir den Gradienten grad durch
Aufrufen der Funktion func_grad mit den Argumenten x und args.
Anschließend verwenden wir Gl. (1.10) um die Variable
x zu aktualisieren. Danach berechnen wir die Norm des Gradienten
mit der Funktion
np.linalg.norm
und prüfen, ob sie kleiner als max_norm ist. Wenn ja, brechen wir die
Schleife mit dem break-Befehl ab.
Nach der letzen Iteration überprüfen wir, ob die Variable niter die maximale
Anzahl an Iterationen erreicht hat. Wenn ja, bedeutet das, dass das verfahren
möglicherweise nicht konvergiert ist und wir geben wir eine Warnung aus.
Am Ende geben wir das Optimum x so wie die Anzahl der tatsächlich benötigten I
terationen niter zurück.
Anwendung
Nun können wir den implementierten Algorithmus auf die Daten aus Kapitel
1.2 anwenden. Dazu definieren wir als erstes
wieder die Arrays concentrations und absorbances:
concentrations = [
2.125, 4.250, 6.375, 8.500, 10.63, 12.75, 14.88, 17.00, 19.13, 21.25,
23.38, 25.50, 27.63, 29.75, 31.88, 34.00, 36.13, 38.25, 40.38, 42.50,
]
absorbances = [
0.0572, 0.1391, 0.2049, 0.2754, 0.3420,
0.4139, 0.4956, 0.5815, 0.6806, 0.7481,
0.8242, 0.9130, 1.0043, 1.0809, 1.1511,
1.2483, 1.3373, 1.4027, 1.4927, 1.5853,
]
concentrations: np.ndarray = np.array(concentrations)
absorbances: np.ndarray = np.array(absorbances)
Dann definieren wir den Startpunkt x0, hier beta_guess, und rufen die Funktion
gradient_descent auf:
beta_guess = np.array([1.0, 1.0])
beta_opt, niter = gradient_descent(
objective_function_gradient,
beta_guess,
alpha=0.00005,
max_iter=100000,
args=(concentrations, absorbances),
)
beta0, beta1 = beta_opt
assert np.isclose(beta0, -0.04907034)
assert np.isclose(beta1, 0.03800109)
print(beta_opt)
print(niter)
Die optimalen Parameter beta0 und beta1 sind identisch wie die der analytischen
Lösung. Auf dem Computer des Autors wurden 34683 Iterationen benötigt, um
die Abbruchbedingung zu erfüllen. Die genaue Anzahl der Iterationen kann
je nach Hardware leicht variieren. Wählt man die Schrittweite alpha
geringfügig größer, werden weniger Iterationen benötigt. Ist alpha
aber zu groß, divergiert das Verfahren.
Versuchen Sie, die Schrittweite alpha zu verändern und beobachten Sie, wie
sich die Anzahl der Iterationen ändert.
Da Optimierung ein sehr allgemeines Problem ist, existieren viele
Implementierungen von verschiedensten Algorithmen in Bibliotheken wie z.B.
scipy.optimize.
Wir wollen die Funktion
scipy.optimize.minimize
verwenden, um die optimalen Parameter zu finden:
from scipy.optimize import minimize
res = minimize(
objective_function,
beta_guess,
args=(concentrations, absorbances),
method='CG',
jac=objective_function_gradient,
options={'maxiter': 10000, 'gtol': 1e-6},
)
beta0, beta1 = res.x
niter = res.nit
assert np.isclose(beta0, -0.04907034)
assert np.isclose(beta1, 0.03800109)
Beim Aufrufen der Funktion minimize müssen wir nur die Objektivfunktion
objective_function und den Startpunkt beta_guess angeben. Um die
Berechnung der numerischen Gradienten kümmert sich die minimize-Funktion
selbst.
Die Funktion minimize akzeptiert auch das Argument jac
(Jacobi-Matrix), also eine Funktion,
die den Gradienten der Objektivfunktion berechnet. Sollte man den analytischen
und leicht zu berechnenden Gradienten zur Verfügung haben, kann man ihn als
Argument jac übergeben, was den Optimierungsprozess beschleunigen kann.
Mit dem Argument method='CG' haben wir die
Methode des nichtlinearen Konjugierten Gradienten
(engl. nonlinear Conjugate Gradient method)
ausgewählt. Diese Methode ist ein Verbesseung des Gradientenverfahrens und
erreicht das Minimum in nur 2 Iterationen auf dem Computer des Autors.
Bei minimize gibt es eine Reihe von weiteren Minimierungsmethoden, die
verwendet werden können. Eine Übersicht finden Sie in der
Dokumentation dieser Funktion. Zwei wichtige Methoden davon sind:
method='Nelder-Mead': Das Nelder-Mead-Verfahren ist eine heutristische Methode, die ohne die Berechnung des Gradienten auskommt. Sie ist daher besonders nützlich, wenn die Berechnung des Gradienten sehr aufwendig ist oder dieser stark variiert. Sie eignet sich deshalb besonders für Regressionen mit experimentellen Daten.method='BFGS': Das Broyden-Fletcher-Goldfarb-Shanno-Verfahren ist eine Methode, die den Gradienten benutzt, um die Hesse-Matrix der Objektivfunktion zu approximieren. Die Hesse-Matrix enthält die zweiten Ableitungen der Funktion nach ihren Parametern. Die Methode zeigt deshaln eine sehr schnelle Konvergenz in der Nähe des Minimums. In der Praxis benögtigt sie weniger Iterationen als andere Optimierungsmethoden und wird deshalb häufig verwendet.
Wir werden in der Übung sehen, dass es für die Regression mit der Methode der kleinsten Quadrate eine geschlossene Lösung gibt, die die optimalen Parameter direkt berechnet. Warum sollten wir dann die numerische Optimierung verwenden? Im Kontext der Regression erlaubt uns die numerische Optimierung einerseits den Einsatz komplizierterer Modelle, die keine analytische Lösung haben, und andererseits die Verwendung sophistizierterer Verlustfunktionen, wie z.B. die der Methode der kleinsten absoluten Abweichungen (vgl. Gl. (1.6)). Zudem können wir damit eine zusätzliche Kontrolle über die Parameter einführen (Regularisierung), was die allgemeine Leistung des Modells verbessern kann.
Es gibt auch die Funktion
scipy.optimize.curve_fit,
die eine (nichtlineare) Regression der Daten direkt durchführt.
Allerdings ist sie nicht so flexibel wie die allgemeine Methode mit der Funktion
minimize, da sie nur über wenige Optimierungsmethoden verfügt und die
Objektivfunktion als die Verlustfunktion der kleinsten Quadrate festlegt.
Sie kann für einfache Regressionen angewendet werden und benötigt in der Regel
weniger Code.
Übung
Aufgabe 1.2: Polynomiale Regression
Basierend auf der vorherigen Aufgabe, in welcher Sie von der linearen
Regression zur quadratischen Regression übergegangen sind, können Sie
bereits vermuten, dass die Methode der kleinsten Quadrate auch mit
höhergradige Polynomen simpel zu implementieren ist. In der Praxis ist es jedoch
nicht sinnvoll, die Gleichungssysteme für Polynome höheren Grades
manuell zu lösen. Stattdessen können Sie die Funktion
np.polyfit
verwenden, um die Koeffizienten eines Polynoms -ten Grades
zu bestimmen, welches am besten zu den Daten passt. Diese Funktion
nimmt als Argumente die Arrays der unabhängigen Variable und
der abhängigen Variable , sowie den Grad des Polynoms entgegen und
gibt die Koeffizienten zurück.
(a) Polynomiale Regression mit np.polyfit
Wenden Sie die Funktion np.polyfit auf die Methylenblau-Daten an, um
ein Polynom 20. Grades zu fitten und plotten Sie das Polynom zusammen mit den Datenpunkten.
Zum Plotten der Polynomfunktion können Sie die Funktion
np.polyval verwenden, welche
die Funktionswerte des Polynoms für gegebene Werte von und (d.h. concentrations) in
Form eines Arrays berechnet.
(b) Vorhersage von neuen Datenpunkten
Aus dem Plot der polynomialen Regression (und ggf. den Residuen) können Sie erkennen, dass das Polynom 20. Grades die Datenpunkte sehr gut anpasst. Dies ist auch nicht weiter verwunderlich, da wir eine Funktion mit mind. 20 Parametern so anpassen können, dass sie unsere 20 Datenpunkte perfekt wiedergibt. Allerdings haben wir in unserem Code bisher lediglich die Datenpunkte, welche wir zum Fitten des Modells verwendet haben, zur Visualisierung der Ergebnisse beachtet. Die Funktionswerte zwischen den Datenpunkten wurden lediglich interpoliert. In der Regel möchten wir allerdings mit Hilfe unseres Modells auch Vorhersagen für neue Datenpunkte erhalten.
Plotten Sie das gesamte Polynom 20. Grades zusammen mit den Datenpunkten in dem Interall
. Definieren Sie sich dazu ein Array mit 1000 Werten mit Hilfe
der Funktion np.linspace
und berechnen Sie die Funktionswerte. Beschränken Sie die Darstellung des Plots auf den Bereich .
Was beobachten Sie?
Nichtlineare Regression
Wir haben in den vorherigen Abschnitten die lineare Regression kennengelernt, die uns erlaubt, lineare Zusammenhänge zwischen Variablen zu modellieren. Zwar sind viele physikalische Zusammenhänge linear, oder können als solche formuliert werden, aber es gibt auch viele nichtlineare Zusammenhänge, die wir modellieren wollen. Im Rahmen der Methode der kleinsten Quadrate ersetzen wir dazu einfach das Modell in Gl. (1.4) durch eine nichtlineare Funktion. Allerdings ist in diesem Fall ist eine analytische Lösung wie Gl. (1.9) nicht immer möglich, weshalb numerische Optimierungsverfahren verwendet werden müssen.
Anwendung
Reaktionskinetik
Sie haben im Physikalisch-Chemischen Praktikum sicherlich den Versuch “Bestimmung der Geschwindigkeitskonstante und der Aktivierungsenergie der Mangan(III)-Trioxalat-Zersetzungsreaktion”, auch “Mn-Zerfall” genannt, durchgeführt. Dort haben Sie die Absorbanz in Abhängigkeit der Zeit gemessen und durch fitten der Messdaten die Geschwindigkeitskonstante bestimmt. Die zugrungeliegende Beziehung ist exponentiell: mit dem Parametern und , d.h. .
Hier gilt also und wir können mit Hilfe der Verlustfunktion der kleinsten Quadrate das Regressionsproblem als das folgende Optimierungsproblem formulieren:
Wir importieren als erstes wieder die benötigten Module und Bibliotheken:
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import minimize
Anschließend müssen wir die Daten in Form von Arrays bereitstellen. Weil wir hier
doch relativ viele Datenpunkte haben, wird das manuelle Eintippen
ziemlich mühsam. Deshalb verwenden wir die Funktion
np.loadtxt,
um die Daten aus einer Textdatei zu lesen.
Die Textdatei mn_decay.txt
(hier herunterladen)
enthält zwei Spalten, welche die Werte für Zeit und
Absorbanz enthalten. Die ersten Zeilen der Datei sehen wie folgt aus:
# time / s absorbance
0 1.625020
30 1.471490
60 1.295880
90 1.169340
120 1.042040
150 0.924193
180 0.824484
210 0.731743
240 0.650593
Zum Einlesen der Daten benötigt die Funktion np.loadtxt den Dateinamen:
time, absorbance = np.loadtxt('mn_decay.txt', unpack=True)
In diesem Fall liegt die Textdatei im gleichen Verzeichnis wie das ausführende Skript.
Wenn Sie die Textdatei in einem anderen Verzeichnis haben, müssen Sie den Pfad
entsprechend anpassen. Das optionale Argument unpack=True sorgt dafür, dass die
Spalten der Datei einzeln ausgegeben und als Arrays time und absorbance gespeichert
werden. Würden wir unpack=False setzen, welches auch der Defaultwert ist,
würde die Funktion ein 2D-Array zurückgeben, in dem die Spalten zusammengefasst sind.
Die erste Zeile dieser Datei beginnt mit einem Kommentarzeichen #, was
np.loadtxt dazu veranlasst, diese Zeile zu ignorieren.
Mit dem optionalen Argument comments können wir das Zeichen, welches für Kommentare
verwendet wird, ändern.
Nun können wir das Modell definieren:
def exp_decay(t: np.ndarray, a0: float, k: float) -> np.ndarray:
return a0 * np.exp(-k * t)
Obwohl t ein Array und k ein Skalar ist, funktioniert die Multiplikation
-k * t elementweise. In diesem Fall wird auch k als ein Array interpretiert, was als
Broadcasting
bezeichnet wird. Die Funktion np.exp berechnet den elementweisen Exponential
des Arrays. Das Ergebnis ist demnach wieder ein Array, welches wir mit dem Skalar a0
multiplizieren.
Anschließend definieren wir die Objektivfunktion in Gl. (1.12):
def objective_function(beta, *args):
a0, k = beta
time, absorbance = args
return np.sum((absorbance - exp_decay(time, a0, k))**2)
Wir verwenden nun die minimize-Funktion, um dieses
Optimierungsproblem zu lösen:
beta_guess = (1.0, 0.01)
res = minimize(
objective_function, beta_guess, method='Nelder-Mead',
args=(time, absorbance),
)
a0, k = res.x
print(f'k = {k} s^-1')
print(f'A0 = {a0}')
Hier haben wir das Nelder-Mead-Verfahren mit den Startparametern
und angewandt. Zum Ausgeben des Ergebnisses mit dem
print-Befehl haben wir vor der Zeichenkette jeweils ein f gesetzt.
Das signalisiert, dass die Zeichenkette ein sog.
f-string
ist, in welchen wir Variablen mit geschweiften Klammern {} einbetten können.
Tatsächlich können f-Strings noch einiges mehr, was wir in Zukunft
noch sehen werden.
Die optimierten Parameter sollten die folgenden Werte haben:
assert np.isclose(k, 0.0039022970)
assert np.isclose(a0, 1.6475263)
Zum Schluss können wir die Ergebnisse plotten:
fig, ax = plt.subplots(figsize=(8, 6))
time_interp = np.linspace(time.min(), time.max(), 1000)
ax.plot(time, absorbance, 'o', label='data')
ax.plot(time_interp, exp_decay(time_interp, a0, k), label='exponential fit')
ax.set_xlabel('time / s')
ax.set_ylabel('absorbance')
ax.legend()
fig.tight_layout()
plt.show()
Sie sollten die meisten Funktionen im obigen Codeblock aus
Kap. 1.2 kennen. Ein Unterschied ist die
Verwendung der Funktion
np.linspace,
welche Zeiten zwischen den Messpunkten generiert, sodass wir
das Regressionsmodell für eine Interpolation verwenden können.
Diese Funktion akzeptiert drei Argumente: den Startwert, den Endwert und
die Anzahl der zu generierenden Punkte. Dann produziert sie ein Array
mit gleichmäßig verteilten Werten zwischen dem Start- und Endwert.
Ein weitere neue Funktion ist
fig.tight_layout(), die eine automatische Anpassung des Layouts des Plots
vornimmt. Wir erkennen aus dem folgendem Diagramm, dass die exponentielle Funktion die Daten
sehr gut beschreibt.
Einige von Ihnen würden vielleicht fragen, warum wir nicht die Funktion
linearisiert haben, um die lineare Regression zu verwenden, was eine
berechtigte Frage ist. Tatsächlich ist es möglich, die Funktion zu linearisieren, indem
wir beide Seiten der Gleichung logarithmieren:
Eine lineare Regression mittels dieser Gleichung liefert allerdings nicht die
gleichen Ergebnisse, wie Sie im folgenden Diagramm sehen können:
Als freiwillige Übung können Sie versuchen, das obige Diagramm reproduzieren. Es ist unschwer zu erkennen, dass der linearisierte Fit schlechter zu den Daten passt. Das liegt daran, dass die lineare Regression die Fehler in der Absorbanz durch das Logarithmieren nicht gleichmäßig behandelt. Die dadurch erhaltenen Parameter
assert np.isclose(k_lin, 0.0041675912)
assert np.isclose(a0_lin, 1.7633374)
sind durchaus unterschiedlich zu den vorherigen. Deshalb ist es oft notwendig, nichtlineare Regressionen an den urprünglichen Daten durchzuführen, anstatt lineare Modelle mit linearisierten Daten zu verwenden.
Titrationskurve
Im Analytikpraktikum haben Sie sicherlich ebenfalls eine Titration einer starken Base gegen eine starke Säure mit einem pH-Meter durchgeführt. Damals mussten Sie die Werte wahrscheinlich auf einem Millimeterpapier auftragen und anhand der Position des pH-Sprungs den Äquivalenzpunkt bestimmen. Das ist einerseits mühsam und andererseits ungenau, da nur die wenigen Messdaten in der Nähe des steilen Anstiegs berücksichtigt werden.
Da die pH-Kurve eine Funktion in Abhängigkeit der zugegebenen Menge an Base ist, können wir sie mithilfe der nichtlinearen Regression modellieren und den Äquivalenzpunkt mit deutlich höherer Genauigkeit bestimmen.
Die -Konzentration während der Titration einer starken Base gegen eine starke Säure ist (unter gewissen Näherungen) gegeben durch: wobei die Konzentrationsdifferenz zwischen den Gegenionen der Säure und der Base ist. ist das Ionenprodukt des Wassers.
Da starke Säuren und Basen vollständig dissoziieren, lassen sich die Konzentrationen ihrer Gegenionen wie folgt ausdrücken: wobei und die Konzentrationen der zu analysierenden Säure und der zugegebenen Base sind, das Anfangsvolumen der Probelösung und das Volumen der zugegebenen Base ist.
Der pH-Wert lässt sich aus der -Konzentration berechnen:
Fasst man die Gleichungen (1.13) und (1.14) in einer Funktion zusammenfassen, so erhalten wir das Modell , wobei .
Wir implmentieren zunächst das Modell und die Objektivfunktion:
def titration_sasb_model(
v_b: np.ndarray,
c0_b: float,
c0_a: float,
v0: float,
) -> np.ndarray:
k_w = 1e-14
c_a = c0_a * v0 / (v0 + v_b)
c_b = c0_b * v_b / (v0 + v_b)
delta = c_a - c_b
c_h = 0.5 * (delta + np.sqrt(delta**2 + 4 * k_w))
ph = -np.log10(c_h)
return ph
def objective_function(beta, *args):
c0_a, v0 = beta
c0_b, v_b, ph = args
ph_fit = titration_sasb_model(v_b, c0_b, c0_a, v0)
return np.sum((ph - ph_fit)**2)
Obwohl die Funktion des pH-Werts relativ kompliziert ist, können wir durch die Definition von Zwischenvariablen, wie in Gl. (1.13), die Implementierung der Funktion in Python deutlich vereinfachen. Die Objektivfunktion ist fast identisch zu der des Mn-Zerfalls, wobei der wesentliche Unterschied die Ersetzung unseres Modells darstellt.
Genau so wie im vorherigen Beispiel lesen wir die Daten aus einer Textdatei (hier herunterladen) ein:
C0_B = 0.1 # mol/l
v_b, ph = np.loadtxt('titration_sasb.txt', unpack=True)
Zusätzlich haben wir hier die Konzentration der Maßlösung C0_B definiert.
Gemäß der allgemeinen Konvention sollen alle Konstanten in Python in Großbuchstaben
geschrieben werden. Anschließend können wir die nichtlineare Regression
durchführen und die Ergebnisse plotten:
beta_guess = (0.01, 100)
res = minimize(
objective_function,
beta_guess,
args=(C0_B, v_b, ph),
method='Nelder-Mead',
)
c0_a, v0 = res.x
print(f'c0_a = {c0_a} mol/l')
print(f'v0 = {v0} ml')
print(f'n0_a = {c0_a * v0} mmol')
fig, ax = plt.subplots(figsize=(8, 6))
v_b_interp = np.linspace(v_b.min(), v_b.max(), 1000)
ax.plot(v_b, ph, 'o', label='data')
ax.plot(
v_b_interp,
titration_sasb_model(v_b_interp, C0_B, c0_a, v0),
label='pH fit',
)
ax.set_xlabel('volume of base / ml')
ax.set_ylabel('pH')
ax.legend()
fig.tight_layout()
plt.show()
Aus den gefitteten Parametern wurde eine Stoffmenge des Analyts von
bestimmt. Das entsprechende Diagramm sollte wie folgt aussehen:
Obwohl der Fit am Anfang und am Ende der Kurve nicht perfekt ist,
ist die Übereinstimmung in der Nähe des Äquivalenzpunkts sehr gut.
Vielleicht haben Sie sich gefragt, warum die optimierten Parameter und hier nicht explizit aufgeführt sind, sondern lediglich ihr Produkt.
Die Modellparameter können korreliert sein, d.h., die Änderung zweier oder mehrerer Parameter führt zu einer ähnlichen Änderung der Objektivfunktion. Dieser Umstand kann auf sog. overfitting der Daten durch das Modell hinweisen. In diesem Fall ist es ratsam zu prüfen, ob das Modell nicht auch mit weniger Parametern auskommt.
In unserem Fall sind die Parameter und korreliert,
da es die Stoffmenge des Analyts ist, die eine wesentliche Auswirkung
auf die Titrationskurve hat. Das Anfangsvolumen dagegen
spielt nur eine untergeordnete Rolle, weshalb das Produkt näherungsweise als ein
Parameter dient. Nichtsdestotrotz ist hier ein wichtiger Parameter, da
das es auf der gleichen Größenordnung wie liegt und
die Verdünnung daher nicht vernachlässigt werden kann. Da man aber für
die Titrationsanalyse nur das Produkt kennen muss,
stört uns die Korrelation der Parameter in diesem Fall nicht.
Ändern Sie die Startparameter und beobachten Sie, wie sich die optimierten Parameter ändern, aber ihr Produkt nahezu konstant bleibt.
Als letztes betonen wir nochmal, dass es oft wichtig ist,
die Regression an den Originaldaten durchzuführen und keine
transformierten Daten zu verwenden. Im Fall der Säure-Base-Titration könnte man z.B. auf
die Idee kommen, anstatt des pH-Werts zu fitten. Aus den gleichen
Gründen wie zuvor ist das allerdings nicht sinnvoll: Da der gleiche Fehler auf der pH-Skala zu größeren
Fehlern bei höheren -Konzentrationen und kleineren Fehlern
bei niedrigeren -Konzentrationen führt, werden
die Datenpunkte bei höheren -Konzentrationen besser
angepasst. Das führt zu einer Verzerrung der Ergebnisse, wie im folgenden
Diagramm zu sehen ist:
Als freiwillige Übung können Sie versuchen, das obige Diagramm reproduzieren.
Man erkennt, dass die Regression an
die früheren Datenpunkte bevorzugt und dadurch den Äquivalenzpunkt
völlig falsch bestimmt wird.
Übung
Aufgabe 1.3: Regularisierung
Das Phänomen, welches Sie bei der polynomialen Regression 20. Ordnung beobachten können, wird als Überanpassung (engl. overfitting) bezeichnet. Es tritt auf, wenn das Modell zu komplex ist und nicht nur der zugrunde liegende Trend, sondern auch das Rauschen in den Daten angepasst wird. In solchen Fällen kann das Modell die Datenpunkte zwar perfekt reproduzieren, aber es wird nicht in der Lage sein, neue Datenpunkte vorherzusagen.
Um Überanpassung zu vermeiden gibt es, neben der Reduzierung der Parameter, die Möglichkeit der Regularisierung. Darunter versteht man die Einführung von zusätzlichen Bedingungen, welche die Komplexität des Modells einschränken. Eine solche Bedingung kann beispielsweise sein, dass die Koeffizienten möglichst klein gehalten werden, was durch die Einführung eines zusätzlichen Terms in die Verlustfunktion erreicht werden kann.
Verwendet man das Quadrat der -Norm der Koeffizienten als Regularisierung und fügt sie der Verlustfunktion hinzu, so spricht man von Ridge-Regression. Die Verlustfunktion ist dann gegeben durch wobei der Parameter die relative Stärke der Regularisierung bestimmt.
(a) Ridge-Regression der Methylenblau-Daten mit Polynom 20. Ordnung
Implementieren Sie die Ridge-Regression für die Methylenblau-Daten mit
und fitten Sie ein Polynom 20. Ordnung. Nutzen Sie dazu die numerische Optimierungsmethode
mit der Funktion minimize und ändern Sie Ihre Objektivfunktion entsprechend. Verwenden Sie
als Startwerte ein Array mit Nullen. Normalisieren Sie außerdem vor der Regression die
Konzentrationen und die Absorptionswerte auf den Bereich , indem Sie jeweils durch den Maximalwert
teilen. Plotten Sie das Ergebnis zusammen mit den Datenpunkten.
Nutzen Sie zur Definition der Verlustfunktion erneut die Funktion np.polyval, sowie
die Funktion np.linalg.norm zur Berechnung der -Norm der Koeffizienten. Vergessen Sie nicht, den
Parameter in die Verlustfunktion einzuführen und der Funktion minimize zu übergeben.
(b) Einfluss des Regularisierungsparameters
Variieren Sie den Regularisierungsparameter und beobachten Sie, wie sich die Stärke der Regularisierung auf die Anpassung des Modells an die Datenpunkte auswirkt. Was passiert, wenn Sie oder wählen?
Differentialgleichungen
Im letzten Kapitel haben wir mit Hilfe der nichtlinearen Regression den zeitlichen Verlauf der Konzentration eines Mn-Komplexes untersucht. Dies ist ein Musterbeispiel für eine Reaktion erster Ordnung, welche durch eine vergleichsweise einfache zeitliche Entwicklung charakterisiert ist. Allerdings gibt es viele weitere chemische Reaktionen, bei denen die Dynamik deutlich komplexer ist. Zu diesem Zweck benötigen wir mathematische Modelle, die die zeitliche Entwicklung von chemischen Systemen beschreiben können, und welche auf Differentialgleichungen (DGLs) basieren.
Ein weiteres wichtiges Anwendungsgebiet von DGLs in der Chemie ist die Beschreibung quantenchemischer Systeme durch die Schrödingergleichung. Sie wissen sicherlich, dass die Schrödingergleichung für Modellsysteme wie das Teilchen im Kasten oder der harmonischen Oszillator analytisch lösbar ist. Für molekulare Systeme ist dies jedoch nicht der Fall. Die Quantenchemie basiert daher auf der (approximativen) numerischen Lösung dieser Gleichung, um die elektronischen Struktur und Eigenschaften von Molekülen zu berechnen.
In diesem Kapitel werden wir uns auf numerische Methoden zur Lösung von Differentialgleichungen konzentrieren, da diese in der Chemie eine wichtige Rolle spielen. Wir werden verschiedene numerische Verfahren kennenlernen und diese auf praktische Beispiele aus der chemischen Kinetik und Quantenchemie anwenden. Durch die Anwendung dieser Methoden werden Sie in die Lage versetzt, komplexe chemische Systeme zu modellieren und die Dynamik dieser Systeme zu verstehen.
Anfangswertproblem
Eine Differentialgleichung ist eine Gleichung, die eine unbekannte Funktion und ihre Ableitungen enthält. Eine allgemeine Form solcher Gleichungen ist kompliziert, da die Funktion und ihre (partiellen) Ableitungen von verschiedester Ordnung und zu unterschiedlichen Potenzen auftreten können. In diesem Abschnitt werden wir uns auf die gewöhnlichen Differentialgleichungen (GDGLs, engl. ODEs) beschränken, bei denen die Funktion nur von einer Variablen abhängt. Außerdem werden wir nur lineare Differentialgleichungen betrachten, bei denen die Funktion und ihre Ableitungen nur in der ersten Potenz auftreten (d.h. ersten Grades sind). Die allgemeine Form einer linearen gewöhnlichen Differentialgleichung lautet: wobei die unabhängige Variable, die gesuchte Funktion, die -te Ableitung dieser Funktion ist und , und gegebene Funktionen von sind. Das größte , für das , ist die Ordnung der Differentialgleichung. Eine lineare Differentialgleichung erster Ordnung hat demnach die Form
Eine allgemeine DGL erster Ordnung kann in der expliziten Form geschrieben werden, wobei eine gegebene Funktion von der unabhängigen Variablen und der gesuchten Funktion ist. Es ist offensichtlich, dass Gl. (2.3) ein spezieller Fall von Gl. (2.2) ist, mit .
Da bei der Bildung der Ableitung der konstante Term verschwindet, ist die (allgemeine) Lösung einer DGL nicht eine eindeutige Funktion, sondern eine Funktionsschar, die durch Integrationskonstanten parametrisiert ist. Um eine eindeutige Lösung zu erhalten, müssen diese Konstanten bestimmt werden, wie z.B. durch die Wahl von Anfangsbedingungen.
Ein Anfangswertproblem (AWP) ist dann ein Problem, bei dem zusätzlich zu der Differentialgleichung eine oder mehrere Anfangsbedingungen gegeben sind. Für eine lineare GDL erster Ordnung ist die Anfangsbedingung durch den Funktionswert gegeben, wobei ein gegebener Punkt und ein gegebener Funktionswert sind. Obwohl wie dies als Anfangsbedingung bezeichnen, muss der gegebene Punkt nicht notwendigerweise der Anfangspunkt, z.B. Zeitpunkt, sein. Die Lösung eines AWP ist dann eine eindeutige Funktion, die auch als spezielle Lösung der DGL bezeichnet wird.
Für eine DGL -ter Ordnung benötigen wir Anfangsbedingungen, um eine spezielle Lösung zu erhalten. Diese sind gegeben durch
Es gibt noch weitere Möglichkeiten, eine spezielle Lösung zu erhalten, z.B. durch die Angabe von Randbedingungen, was Sie in der Übung kennenlernen werden.
Euler-Verfahren
Wir betrachten das Anfangswertproblem wobei eine gegebene Funktion von der unabhängigen Variablen und der gesuchten Funktion ist. Gl. (2.4) ist ein AWP erster Ordnung, mit einer DGL erster Ordnung wie in Gl. (2.3) und einer Anfangsbedingung.
Unser Ziel ist es, die spezielle Lösung des AWP numerisch zu finden. Wie bei vielen numerischen Verfahren, beginnen wir mit einer Diskretisierung der Funktion , d.h. wir wählen eine Menge von Punkten und betrachten die Funktion nur an diesen Punkten anstatt auf dem gesamten Definitionsbereich. Die konzeptionell wohl einfachste Wahl der Punkte ist ein gleichmäßiges Gitter (Grid), was bedeutet, dass man einen Anfangspunkt und eine Schrittweite wählt, so dass die weiteren Punkte durch für festgelegt werden. Unser Ziel in den folgenden Abschnitten wird es sein, die Funktionswerte von an diesen Punkten zu berechnen, bzw. zu approximieren.
Der Funktionswert von an dem Punkt kann (unter bestimmten Voraussetzung an ) durch eine Taylor-Entwicklung um den Punkt ersetzt werden, also
Info für Mathematik-Interessierte
Die Voraussetzung ist, dass im Punkt analytisch ist, d.h. dass es eine Potenzreihe gibt, die für alle konvergiert, wobei der Konvergenzradius der Potenzreihe ist. Zudem muss der Konvergenzradius größer als die Schrittweite sein, also .
Theoretische Grundlagen
Gehen wir nun davon aus, dass die Funktion gut durch ihr Taylor-Polynom 1. Ordnung approximiert werden kann, also dass der Fehler , der proportional zu ist, klein ist. Mathematisch unsauber, aber praktisch für die Implementierung, schreiben wir im Folgenden wobei wir den Fehler vernachlässigen. Gl. (2.5) zeigt, dass wir, wenn wir den Funktionswert und die Ableitung an dem Punkt kennen, den Funktionswert an dem nächsten Punkt berechnen können. Mit Hilfe der DGL in Gl. (2.4) können wir dabei die Ableitung durch ersetzen, was zu führt. Um zu betonen, dass die Euler-Methode nur diskrete Werte von liefert, schreiben wir und , was zu führt. Gl. (2.6) beschreibt das explizite Euler-Verfahren für die numerische Lösung eines AWP erster Ordnung. Durch die Anfangsbedingung kennen wir und können dann, Schritt für Schritt, alle weiteren mit Hilfe von Gl. (2.6) berechnen.
Implementierung
Mn-Zerfall
Wir nehmen wieder den Mn-Zerfall als Beispiel. Dort haben wir eine Reaktion 1. Ordnung, welche durch die DGL beschrieben wird, wobei die Konzentration des Mn-Komplexes zum Zeitpunk beschreibt. Wir können nach dem Importieren der benötigten Libraries
import numpy as np
import matplotlib.pyplot as plt
from typing import Callable
die Funktion dydx implementieren, die berechnet:
def dydx(x: float, y: float) -> float:
k = 0.0039022970
return -k * y
Diese Funktion akzeptiert die Argumente x () und y () und
gibt die Ableitung zurück. Hier haben wir die gefittete
Geschwindigkeitskonstante aus Abschnitt
1.4 verwendet.
Anschließend können wir gemäß Gl. (2.6) die Funktion euler_step
implementieren, die den Funktionswert berechnet:
def euler_step(
x_n: float,
y_n: float,
h: float,
dydx: Callable[[float, float], float],
) -> float:
return y_n + h * dydx(x_n, y_n)
Wir können jetzt das Euler-Verfahren implementieren:
def euler_method(
x0: float,
y0: float,
h: float,
dydx: Callable[[float, float], float],
nsteps: int,
) -> np.ndarray:
x = x0 + np.arange(0, nsteps + 1) * h
y = np.zeros(nsteps + 1)
y[0] = y0
for i in range(0, nsteps):
y[i + 1] = euler_step(x[i], y[i], h, dydx)
return x, y
Diese Funktion akzeptiert neben der Anfangsbedingungen x0 und y0 die
Schrittweite h, die Ableitungsfunktion dydx und die Anzahl der Schritte
n. Wir erstellen zunächst das Grid x mit der Funktion
np.arange
und initialisieren das Nullarray y, um später die Lösung zu speichern. Dann wird
der erste Eintrag y[0] dieses Arrays mit
dem Anfangswert y0 überschrieben. Anschließend verwenden wir eine for-Schleife
über die Anzahl der Schritte und rufen in jedem Schritt die Funktion
euler_step auf, die den Funktionswert an dem jeweils nächsten Punkt berechnet,
und speichern diesen in y[i + 1]. Am Ende wird das Grid x und die Lösung y
zurückgegeben.
Nun wenden wir das Euler-Verfahren auf Gl. (2.7) an:
C0 = 1.0 # M
T0 = 0.0
STEP = 1.0 # s
MAXTIME = 900.0 # s
nsteps = int(MAXTIME / STEP)
x, y = euler_method(T0, C0, STEP, dydx, nsteps)
Wir setzen dazu zunächst die Anfangsbedingungen C0 = 1.0 und T0 = 0.0, die Schrittweite
h = 1.0 sowie die maximale Zeit MAXTIME = 600.0. Die Anzahl der Schritte
nsteps wird durch int(MAXTIME / h) berechnet. Die int-Funktion rundet
dabei das Ergebnis der Division ab und konvertiert es in eine Ganzzahl. Anschließend rufen
wir die Funktion euler_method auf und speichern das Ergebnis in x und y.
Zum Schluss können wir das numerische Ergebnis mit der analytischen Lösung vergleichen. Dabei sei angemerkt, dass die analytische Lösung von Gl. (2.7) leicht zu berechnen ist und durch gegeben ist.
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(x, C0 * np.exp(-0.0039022970 * x), label='analytical solution')
ax.plot(x, y, label='numerical solution')
axins = ax.inset_axes(
[0.4, 0.5, 0.27, 0.47], # position and size of the inset
xlim=(300, 305), ylim=(0.3, 0.315), # limits of the inset
xticks=[], yticks=[] # remove ticks
)
axins.plot(x, C0 * np.exp(-0.0039022970 * x))
axins.plot(x, y)
ax.indicate_inset_zoom(axins, edgecolor="black")
ax.set_xlabel('time / s')
ax.set_ylabel('concentration / M')
ax.legend(loc='upper right')
fig.tight_layout()
plt.show()
Dabei sollte dieses Diagramm erscheinen:
Erst durch Vergrößerung des Konzentrationsverlaufes können wir den Unterschied zwischen der analytischen
und der numerischen Lösung erkennen, was hier mit Hilfe des Befehls ax.inset_axes erreicht wurde
(die genaue Funktionsweise dieses Befehls ist an dieser Stelle unwichtig). Das Euler-Verfahren mit liefert
demnach eine sehr gute Approximation.
Belousov-Zhabotinsky-Reaktion
Nun wollen wir eine Reaktion mit deutlich komplizierterer Kinetik betrachten. Die Belousov-Zhabotinsky-Reaktion ist ein klassisches Beispiel einer oszillierenden Reaktion, wobei ein Redox-System ( in der Originalreaktion) abwechselnd in der oxidierten und der reduzierten Form vorliegt. In dem folgenden Video können Sie die Reaktion beobachten:
Hier wird allerdings als Redox-System Ferroin verwendet, welches eine stärkere Farbänderung zeigt.
Der detaillierte Mechanismus dieser Reaktion ist sehr kompliziert, weshalb wir hier nur eine vereinfachte Version, das sog. Oregonator-Modell, betrachten. Ein häufig verwendetes Oregonator-Modell besteht aus fünf gekoppelten Reaktionen mit sechs Spezies:[1, 2]
wobei , , , , und . Möglicherweise fällt Ihnen auf, dass die obigen Reaktionsgleichungen nicht ausbalanciert sind. Der Grund dafür ist, dass die Konzentrationen einiger Spezies entweder aufgrund ihrer hohen Konzentration (, Malonsäure, etc.) oder ihrer schnellen Reaktionen () als konstant angenommen werden können. Insbesondere wird angenommen, dass die Konzentrationen und konstant sind.
Das führt zu einem System von drei gekoppelten DGLs:
Ein wichtiger Unterschied zum Mn-Zerfall ist, dass wir hier ein System von DGLs vorliegen haben. Glücklicherweise ist Gl. (2.6) für DGL-Systeme genauso gültig wie für einzelne DGLs, sofern wir als eine vektorwertige Funktion ansetzten, d.h. .
Nachdem wir die benötigten Libraries importiert haben
import numpy as np
import matplotlib.pyplot as plt
from typing import Callable
können wir die Funktion dydx für das Oregonator-Modell implementieren:
def dydx(x: float, y: np.ndarray) -> np.ndarray:
# concentrations adapted from
# R. J. Field, H.-D. Försterling, J. Phys. Chem. 1986, 90, 5400–5407.
k1 = 1.3 # M^-1 s^-1
k2 = 2.4e6 # M^-1 s^-1
k3 = 34.0 # M^-1 s^-1
k4 = 3.0e3 # M^-1 s^-1
k5 = 1.0 # M^-1 s^-1
c_a = 0.1 # M
c_b = 0.4 # M
c_x, c_y, c_z = y
dcxdt = k1 * c_a * c_y - k2 * c_x * c_y + k3 * c_a * c_x - 2.0 * k4 * c_x**2
dcydt = -k1 * c_a * c_y - k2 * c_x * c_y + k5 * c_b * c_z
dczdt = k3 * c_a * c_x - k5 * c_b * c_z
return np.array([dcxdt, dcydt, dczdt])
Beachten Sie, dass der Datentyp des Arguments y ein np.ndarray ist.
Die Funktionen euler_step und euler_method sind analog zu den
Funktionen für den Mn-Zerfall implementiert, nur dass wir hier die
Typ-Deklarationen in den Signaturen anpassen müssen.
def euler_step(
x_n: float,
y_n: np.ndarray,
h: float,
dydx: Callable[[float, np.ndarray], np.ndarray],
) -> np.ndarray:
return y_n + h * dydx(x_n, y_n)
def euler_method(
x0: float,
y0: np.ndarray,
h: float,
dydx: Callable[[float, np.ndarray], np.ndarray],
nsteps: int,
) -> np.ndarray:
ndim = len(y0)
x = x0 + np.arange(0, nsteps + 1) * h
y = np.zeros((ndim, nsteps + 1))
y[:, 0] = y0
for i in range(0, nsteps):
y[:, i + 1] = euler_step(x[i], y[:, i], h, dydx)
return x, y
Ein weiterer Unterschied ist, dass die Variable y in der Funktion
euler_method als ein 2D-Array initialisiert wird, wobei die erste Dimension
die Anzahl der Komponenten angibt (hier: ndim = 3) und die zweite Dimension
die Anzahl der Schritte enthält. Das Grid x bleibt ein 1D-Array,
da die Zeit für alle Komponenten gleichermaßen gilt.
Nun lösen wir das DGL-System in Gl. (2.8) mit dem Euler-Verfahren:
CX_0 = 0.0 # M
CY_0 = 0.001 # M
CZ_0 = 0.0 # M
C0 = np.array([CX_0, CY_0, CZ_0])
T0 = 0.0
STEP = 1.0
TMAX = 200.0
nsteps = int(TMAX / STEP)
x, y = euler_method(T0, C0, STEP, dydx, nsteps)
Hier haben wir die Anfangsbedingungen C0 = np.array([0.0, 0.001, 0.0])
gewählt, was bedeutet, dass zu Beginn nur die Spezies
mit einer Konzentration von vorhanden ist.
Die Spezies und liegen nicht vor.
Dieser Code-Block schafft es jedoch nicht, uns die richtige Lösung zu liefern. Der Code wird zwar ohne Fehlermeldung ausgeführt, wir erhalten jedoch eine Reihe von Warnungen, wie z.B.
/path/to/your/python_script.py:line: RuntimeWarning: overflow encountered in xxxxx
Sollten Sie das Ergebnis plotten, werden Sie feststellen, dass
die Komponenten in y betragsmäßig sehr groß werden, was zu einem
arithmetischen Überlauf
(engl. overflow) führt.
Das kann ein Zeichen dafür sein, dass wir die Schrittweite zu groß gewählt haben
und das Euler-Verfahren instabil wird.
In diesem Fall müssen wir die Schrittweite tatsächlich auf h = 0.0005 verkleinern,
um eine stabile numerische Lösung zu erhalten:
CX_0 = 0.0 # M
CY_0 = 0.001 # M
CZ_0 = 0.0 # M
C0 = np.array([CX_0, CY_0, CZ_0])
T0 = 0.0
STEP = 0.0005
TMAX = 200.0
nsteps = int(TMAX / STEP)
x, y = euler_method(T0, C0, STEP, dydx, nsteps)
Es sei angemerkt, dass sich dadurch natürlich auch die Anzahl der Schritte erhöht; hier waren es Schritte. Für Anfangsbedingungen wären die DGLs sogar noch schwieriger zu lösen und wir müssten eine noch kleinere Schrittweite verwenden.
Nun plotten wir das Ergebnis:
c_x, c_y, c_z = y * 1000.0 # convert to mM
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(x, c_x, label='[HBrO2]')
ax.plot(x, c_y, label='[Br-]')
ax.plot(x, c_z, label='2 [Ce4+]')
ax.set_xlabel('time / s')
ax.set_ylabel('concentration / mM')
ax.set_xlim(0, 200)
ax.set_ylim(-0.1, 2.0)
fig.tight_layout()
ax.legend(loc='upper right')
plt.show()
Wir haben hier mit den Funktionen ax.set_xlim und ax.set_ylim die Achsen
eingeschränkt. Außerdem haben wir das Argument loc='upper right' an die
Funktion ax.legend übergeben, um die Legende nach oben rechts zu verschieben.
Das voreingestellte Argument ist loc='best', was die “beste” Position für
die Legende automatisch bestimmt.
Wir erhalten das folgende Diagramm:
Man erkennt hier den periodischen, impulsartigen Verlauf von ,
was in dem obigen Video der Konzentration von Fe(III) entspricht ( in der Originalreaktion)
und dort als Blaufärbung zu sehen ist.
Woher weiß man nun, ob die Schrittweite klein genug ist? Eine Faustregel besagt, dass man bei erfolgter Rechnung mit gegebenem die Rechnung mit halbierter Schrittweite erneut durchführen soll. Bleibt das Ergebnis gleich wie mit , dann ist klein genug.
Unabhängig davon, ob klein genug ist oder nicht, konnten wir erkennen, dass das Euler-Verfahren Schwierigkeiten hat das Gleichungssystem (2.8) zu lösen. Wir werden uns im folgenden Abschnitt mit Methoden befassen, die trotz größerer Schrittweiten stabile Lösungen liefern können. Dabei sei daran erinnert, dass das Euler-Verfahren nur den konstanten und linearen Term der Taylor-Entwicklung berücksichtigt (vgl. Gl. (2.5)). Es liegt daher nahe, auch höhere Ordungen zu berücksichitgen, was zu einer Familie von Methoden führt, die als Runge-Kutta-Verfahren bekannt sind.
R. J. Field, H.-D. Försterling, J. Phys. Chem. 1986, 90, 5400–5407.
F. W. Schneider, A. F. Münster, in Nichelineare Dynamik in der Chemie, Spektrum Akademischer Verlag, Heidelberg, 1996, pp. 67–72.
Übung
Aufgabe 2.1: Lösen des klassischen harmonischen Oszillators mit Euler-Verfahren
Die Bewegungsgleichung eines harmonischen Oszillators ist durch die Differentialgleichung zweiter Ordnung
gegeben, wobei . Um diese Gleichung mit dem Euler-Verfahren zu lösen, muss sie zunächst in ein System von Differentialgleichungen erster Ordnung umgeformt werden.
(a) Umformen in ein System von Differentialgleichungen erster Ordnung
Zeigen Sie, dass die obige Differentialgleichung in das System von gekoppelten Differentialgleichungen erster Ordnung
umgeformt werden kann, indem Sie die Substitution verwenden.
(b) Implementieren des Euler-Verfahrens
Implementieren Sie das Euler-Verfahren, um das System von Differentialgleichungen erster Ordnung
aus Teilaufgabe (a) zu lösen. Gehen Sie dazu wie in der Vorlesung vor,
indem Sie die Funktionen dfdt, welche die rechte Seite der Differentialgleichungen berechnet,
euler_step, welche einen Schritt des Euler-Verfahrens durchführt, und euler_method, welche das
Euler-Verfahren für eine gegebene Anzahl von Schritten durchführt, implementieren.
Ähnlich zur Kinetik der BZ-Reaktion hat die Lösung des harmonischen Oszillators zwei Komponenten,
und , dessen Ableitungen in Form eines Arrays [dxdt, dvdt] gespeichert werden können.
(c) Herleitung der analytischen Lösung
Zeigen Sie, dass sie analytische Lösung der Bewegungsgleichung des harmonischen Oszillators mit Anfangsbedingungen und gegeben ist durch
Nutzen Sie dazu den allgemeinen Lösungsansatz .
Plotten Sie die analytische Lösung und die numerische Lösung des Euler-Verfahrens für die Anfangsbedingungen , , mit einer Schrittweite von für . Was beobachten Sie?
Runge-Kutta-Verfahren
Die Runge-Kutta-Verfahren sind eine nach Carl Runge und Martin Wilhelm Kutta benannte Familie von Methoden zur numerischen Lösung von Anfangswertproblemen für gewöhnliche Differentialgleichungen. Diese Methoden berechnen iterativ die Lösung im nächsten Zeitschritt aus einer Linearkombination von dem Funktionswert und den Steigungen an verschiedenen Stellen. Sprich man jedoch von dem Runge-Kutta-Verfahren, ist das klassische Runge-Kutta-Verfahren gemeint, was aber nur ein Spezialfall der Runge-Kutta-Verfahren darstellt.
Theoretische Grundlagen
Wir betrachten wieder das Anfangswertproblem (AWP) in Gl. (2.4). Genau so wie beim Euler-Verfahren, wählen wir zunächst ein gleichmäßiges Grid mit und als Schrittweite, sowie eine Anfangsbedingung . Dann entwickeln wir wieder in eine Taylor-Reihe um : Man spricht von einem Runge-Kutta-Verfahren der (Konsistenz-)Ordnung , wenn das Taylorpolynom bis zum -ten Grad im Verfahren berücksichtigt wird.
Ein allgemeines Runge-Kutta-Verfahren für das AWP in Gl. (2.4) ist dann durch gegeben, wobei Dabei bezeichnet die Stufe des Verfahrens. Die Koeffizienten , und sind charakteristische Parameter des Verfahrens. Hier werden wir nur explizite Runge-Kutta-Verfahren betrachten, bei denen die Koeffizienten und für gelten.
Weil die allgemeine Form doch sehr unhandlich ist, betrachten wir zuerst ein zwei-stufiges Verfahren zweiter Ordnung.
Runge-Kutta-Verfahren zweiter Ordnung (RK2)
Nach Gl. (2.10) können wir ein explizites zwei-stufiges Runge-Kutta-Verfahren zweiter Ordnung formulieren als
Für ein Verfahren zweiter Ordnung benötigen wir ein Taylorpolynom zweiten Grades, in dem die zweite Ableitung von vorkommt. Da wir aber nicht kennen, haben wir keinen direkten Zugriff auf die zweite Ableitung. Wir können jedoch mit Hilfe der Kettenregel wie folgt ausdrücken: Das liefert und das Taylorpolynom zweiten Grades:
Anschließend entwickeln wir in Gl. (2.11) linear um : Setzen wir dies in Gl. (2.11) ein, erhalten wir
Damit das Verfahren tatsächlich eine Konsistenzordnung von hat, müssen die Koeffizienten vor den Funktionen und ihren Ableitungen in Gl. (2.12) und (2.13) übereinstimmen, da diese für beliebige Funktionen gelten müssen. Das führt zu den Bedingungen Das ist ein unterbestimmtes Gleichungssystem mit drei Gleichungen für vier Unbekannte. Wir können demnach einen der Koeffizienten frei wählen und erhalten so eine Familie von konsistenten Runge-Kutta-Verfahren zweiter Ordnung mit zwei Stufen.
Wählen wir , und , erhalten wir das sog. Heun-Verfahren. Wählen wir dagegen , , und , erhalten wir das sog. Mittelpunktsverfahren. Neben diesen beiden geläufigen Verfahren kann man natürlich auch andere Kombinationen der Koeffizienten wählen, solange die Bedingungen in Gl. (2.14) erfüllt sind. Eine allgemeine Parametrisierung der Koeffizienten lautet
Die Angabe der einzelnen Koeffizienten auf diese Weise ist jedoch nicht sehr übersichtlich, wenn man bedenkt, dass es auch Verfahren mit mehr als zwei Stufen gibt. Daher gibt es eine kompaktere Schreibweise, das sog. Butcher-Tableau.
Butcher-Tableau
Das Butcher-Tableau ist eine übersichtliche Darstellung der Koeffizienten , und eines Runge-Kutta-Verfahrens. Das Tableau für ein allgemeines -stufiges Verfahren lautet
wobei die Koeffizienten mit einem Index ( und ) als Vektoren und die Koeffizienten mit zwei Indices () als eine Matrix dargestellt werden.
Das Heun-Verfahren kann damit als und das Mittelpunktsverfahren als dargestellt werden. Die allgemein parametrisierte Form eines 2-stufigen Verfahrens zweiter Ordnung lautet dann
Als eine letzte Bemerkung sei noch gesagt, dass die Bedingung für bei expliziten Runge-Kutta-Verfahren in dieser Darstellung bedeutet, dass die Matrix eine strikte untere Dreiecksmatrix ist.
Klassisches Runge-Kutta-Verfahren (RK4)
Das am häufigsten verwendete Runge-Kutta-Verfahren ist das klassische Runge-Kutta-Verfahren. Diese ist eine vierstufige Methode vierter Ordnung, hat also die Form Die Bedingungen für die Koeffizienten kann auf die gleiche Weise wie beim RK2 hergeleitet werden; die Rechnungen sind jedoch deutlich aufwendiger, weswegen wir sie hier nicht durchführen.
Beim klassischen Runge-Kutta-Verfahren lauten die Koeffizienten Eine weiteres, in dem gleichen Paper[1] wie das klassische RK4-Verfahren vorgestellte, aber bei weitem nicht so bekanntes Verfahren nutzt die Koeffizienten
Sie wundern sich vielleicht, warum das klassische RK4-Verfahren so beliebt ist, obwohl es Verfahren mit höherer Ordnung gibt. Dabei sollte man allerdings das folgende bedenken:
Die Konsistenzordnung und die Stufe des Verfahrens sind zwei verschiedene Dinge, obwohl wir bis jetzt nur Verfahren mit betrachtet haben. Tatsächlich gilt für die minimale Stufenzahl zum Erreichen einer Konsistenzordnung bei expliziten Runge-Kutta-Verfahren stets .
Man kann sogar zeigen, dass für die strikte Ungleichung gilt.[2] In anderen Worten: Die Verbesserung der Genauigkeit von auf unter Verwendung von expliziten Runge-Kutta-Verfahren ist mit einer Erhöhung der Stufezahl um mindestens 2 verbunden. Dies erklärt, warum das klassische RK4-Verfahren so beliebt ist.
Der Zusammenhang zwischen und für einige Ordnungen von expliziten Runge-Kutta-Verfahren ist in der folgenden Tabelle zusammengefasst:[2]
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
| 1 | 2 | 3 | 4 | 6 | 7 | 9 | 11 |
Die Zahlen sind auch als Butcher-Schranken bekannt.
M. W. Kutta, Z. Math. Phys. 1901, 46, 435–453.
J. C. Butcher, in The Numerical Analysis of Ordinary Differential Equations, John Wiley & Sons, Chichester, 1987, pp. 185–194.
Implementierung
Wir verwenden als Beispiel erneut die Dynamik der Belousov-Zhabotinsky-Reaktion. Genau wie im Abschnitt 2.2 importieren wir zunächst die notwendigen Libraries
import numpy as np
import matplotlib.pyplot as plt
from typing import Callable
und kopieren die Implementierung der Funktion dydx.
def dydx(x: float, y: np.ndarray) -> np.ndarray:
# concentrations adapted from
# R. J. Field, H.-D. Försterling, J. Phys. Chem. 1986, 90, 5400–5407.
k1 = 1.3 # M^-1 s^-1
k2 = 2.4e6 # M^-1 s^-1
k3 = 34.0 # M^-1 s^-1
k4 = 3.0e3 # M^-1 s^-1
k5 = 1.0 # M^-1 s^-1
c_a = 0.1 # M
c_b = 0.4 # M
c_x, c_y, c_z = y
dcxdt = k1 * c_a * c_y - k2 * c_x * c_y + k3 * c_a * c_x - 2.0 * k4 * c_x**2
dcydt = -k1 * c_a * c_y - k2 * c_x * c_y + k5 * c_b * c_z
dczdt = k3 * c_a * c_x - k5 * c_b * c_z
return np.array([dcxdt, dcydt, dczdt])
RK4-Verfahren
Dann implementieren wir die Funktion rk4_step, die den Funktionswert
mit Hilfe des RK4-Verfahrens gemäß
Gl. (2.16) berechnet.
def rk4_step(
x_n: float,
y_n: np.ndarray,
h: float,
dydx: Callable[[float, np.ndarray], np.ndarray],
) -> np.ndarray:
a21 = 1.0 / 3.0
a31 = -1.0 / 3.0
a32 = 1.0
a41 = 1.0
a42 = -1.0
a43 = 1.0
b1 = 1.0 / 8.0
b2 = 3.0 / 8.0
b3 = 3.0 / 8.0
b4 = 1.0 / 8.0
c2 = 1.0 / 3.0
c3 = 2.0 / 3.0
c4 = 1.0
k1 = dydx(x_n, y_n)
k2 = dydx(x_n + h * c2, y_n + h * a21 * k1)
k3 = dydx(x_n + h * c3, y_n + h * (a31 * k1 + a32 * k2))
k4 = dydx(x_n + h * c4, y_n + h * (a41 * k1 + a42 * k2 + a43 * k3))
return y_n + h * (b1 * k1 + b2 * k2 + b3 * k3 + b4 * k4)
Die Funktion sieht zwar auf den ersten Blick kompliziert aus, ein Großteil der Zeilen ist allerdings nur für die Definition der Koeffizienten des RK4-Verfahrens belegt, wobei wir die weniger bekannte Variante des RK4- Verfahrens verwendet haben. Danach werden die vier Stufen berechnet und zum Schluss die Lösung entsprechend Gl. (2.16) ausgegeben.
Als nächstes implementieren wir die Funktion rk4_method:
def rk4_method(
x0: float,
y0: np.ndarray,
h: float,
dydx: Callable[[float, np.ndarray], np.ndarray],
nsteps: int,
) -> np.ndarray:
ndim = len(y0)
x = x0 + np.arange(0, nsteps + 1) * h
y = np.zeros((ndim, nsteps + 1))
y[:, 0] = y0
for i in range(0, nsteps):
y[:, i + 1] = rk4_step(x[i], y[:, i], h, dydx)
return x, y
Diese Funktion ist tatsächlich identisch mit der Funktion euler_method aus
dem Abschnitt 2.2, nur dass wir hier
rk4_step statt euler_step aufrufen. Man könnte auch eine allgemeingültige
Funktion rk_method schreiben, die als Argument rk_step sowohl euler_step als auch rk4_step akzeptiert.
Zum Schluss lösen wir das AWP mit dem RK4-Verfahren:
CX_0 = 0.0 # M
CY_0 = 0.001 # M
CZ_0 = 0.0 # M
C0 = np.array([CX_0, CY_0, CZ_0])
T0 = 0.0
STEP = 0.001
TMAX = 200.0
nsteps = int(TMAX / STEP)
x, y = rk4_method(T0, C0, STEP, dydx, nsteps)
Leider wird hier immer noch eine Schrittweite von h = 0.001 benötigt, um
eine stabile Lösung zu erhalten. Dann plotten wir wieder die Lösung:
c_x, c_y, c_z = y * 1000.0 # convert to mM
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(x, c_x, label='[HBrO2]')
ax.plot(x, c_y, label='[Br-]')
ax.plot(x, c_z, label='2 [Ce4+]')
ax.set_xlabel('time / s')
ax.set_ylabel('concentration / mM')
ax.set_xlim(0, 200)
ax.set_ylim(-0.1, 2.0)
fig.tight_layout()
ax.legend(loc='upper right')
plt.show()
Optisch sollte das Ergebnis identisch wie das des Euler-Verfahrens sein:
Ein möglicher Grund, warum das RK4-Verfahren nur eine geringfügig größere Schrittweite als das Euler-Verfahren verwenden kann, ist, dass das AWP steif ist, also dass explizite Verfahren erhebliche Schwierigkeiten haben, eine stabile Lösung zu finden.
Um das AWP mit weniger Schritten zu lösen, können wir
z.B. adaptive Schrittweitenverfahren verwenden, welche die Schrittweite
an schwierigen Stellen automatisch verkleinern. Alternativ können wir
implizite Verfahren verwenden, die stabiler sind als explizite Verfahren.
Wir wollen uns hier jedoch nicht mit Details dieser Verfahren beschäftigen,
sondern lediglich diskutieren, wie und wann man sie einsetzen sollte.
Deshalb werden wir im folgenden Abschnitt die Funktion solve_ivp aus der
Bibliothek scipy verwenden, die eine Vielzahl von Verfahren zur Lösung
von AWP bereitstellt.
Lösen von AWP mit scipy.integrate.solve_ivp
Die Funktion
scipy.integrate.solve_ivp
bietet ein universelles Interface für eine Vielzahl von Verfahren zur Lösung
von AWP. Wir importieren diese Funktion sowie andere notwendige Libraries wie
gewohnt:
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp
Wir nutzen hier wieder die Funktion dydx vom Oregonator-Modell.
Danach definieren wir die Anfangsbedingungen und die Parameter für den
DGL-Solver, genau so wie wir bisher gemacht haben:
CX_0 = 0.0 # M
CY_0 = 0.001 # M
CZ_0 = 0.0 # M
C0 = np.array([CX_0, CY_0, CZ_0])
T0 = 0.0
TMAX = 200.0
MAXSTEP = 0.1
Ein Unterschied hier ist, dass wir die Konstante STEP durch MAXSTEP
ersetzt haben, da der Algorithmus von solve_ivp die Schrittweite selbst
anpasst und wir nur eine obere Schranke setzen können.
Danach rufen wir die Funktion solve_ivp auf mit der Methode RK45:
res = solve_ivp(
dydx,
(T0, TMAX),
C0,
method='RK45',
max_step=MAXSTEP,
)
x, y = res.t, res.y
nsteps = len(x) - 1
minstep = np.min(np.diff(x))
print(nsteps)
print(minstep)
Als Ergebnis erhalten wir ein Objekt mit verschiedenen nützlichen Attributen.
Das Grid ist im Attribut t gespeichert und die
Lösung im Attribut y. Die Anzahl der Schritte berechnen wir aus der
Länge des Grids abzüglich 1 (Anfangsbedingung).
RK45 ist ein adaptives Runge-Kutta-Verfahren mit einer Konsistenzordnung
von 5, weshalb die Schrittweite nicht konstant ist. Trotzdem können wir
die kleinste Schrittweite berechnen, indem wir zuerst die Differenz zwischen
allen Gridpunkten mit
np.diff
berechnen und dann das Minimum davon mit
np.min
bestimmen. Die minimale Schrittweite gibt an, mit welcher Präzision das Verfahren die
Lösung an den schwierigsten Stellen berechnet hat und bietet einen guten
Vergleich gegenüber Verfahren mit konstanter Schrittweite.
In diesem Fall werden nur knapp 30.000 Schritte benötigt, um eine stabile Lösung des AWPs zu erhalten. Das ist deutlich weniger als die 200.000 Schritte, die unsere Implementierung des klassischen RK4-Verfahrens benötigt hat. Die minimale Schrittweite beträgt dabei ca. 0.0012, also nur unwesentlich größer als die Schrittweite beim RK4-Verfahren.
assert nsteps == 27611
assert np.isclose(minstep, 0.00121700)
Die Erhöhung der Ordnung von 4 auf 5 hat dem Lösungsverfahren demnach kaum geholfen; die adaptive Schrittweitensteuerung dagegen schon.
Testen Sie die Funktion solve_ivp mit den Argumenten method='DOP853'
und max_step=0.02. DOP8(5,3) ist ein adaptives Runge-Kutta-Verfahren
mit einer Konsistenzordnung von 8. Sie werden feststellen, dass die
minimale Schrittweite bei ca. 0.002 liegt, also trotz der hohen Ordnung
immer noch sehr klein ist. Das bestätigt die Aussage, dass das AWP steif ist.
Nun probieren wir ein implizites Verfahren aus, z.B. mit method='Radau'
(und wieder MAXSTEP=0.1):
res = solve_ivp(
dydx,
(T0, TMAX),
C0,
method='Radau',
max_step=MAXSTEP,
)
x, y = res.t, res.y
nsteps = len(x) - 1
minstep = np.min(np.diff(x))
print(nsteps)
print(minstep)
Dabei sollten Sie ungefähr die folgenden Werte für nsteps und minstep erhalten:
assert nsteps == 2014
assert np.isclose(minstep, 0.01494846)
Hier gibt es einen erheblicher Unterschied: Das Radau-Verfahren benötigt nur knapp über 2000 Schritte und die minimale Schrittweite ist ca. 0.015. Dass implizite Verfahren die Stabilität der Lösung signifikant verbessern, ist eine weitere Eigenschaft von steifen AWPs.
Wir könnten die Lösung des AWPs der Belousov-Zhabotinsky-Reaktion an dieser Stelle erneut plotten, was uns allerdings keine neuen Erkenntnisse liefern wird. Stattdessen widmen wir uns zwei weiteren Visualierungsmethoden von Lösungen von AWPs: Konfigurationsraum- und Phasenraumtrajektorien.
Konfigurationsraum und Phasenraum
Der Konfigurationsraum ist der Raum der Freiheitsgrade eines Systems. Für das Oregonator-Modell sind das die Konzentrationen der drei Spezies, also , und . Die Lösung des DGL-Systems zum Zeitpunkt ist also durch den Punkt im Konfigurationsraum gegeben. Die Zeitentwicklung des Systems kann demnach durch eine Reihe von Punkten im Konfigurationsraum beschrieben. Die Ansammlung dieser Punkte wird dann als Konfigurationsraumtrajektorie bezeichnet.
Da die Information, zu welchem Zeitpunkt welcher Punkt im Konfigurationsraum durchlaufen
wird, verloren geht, wollen wir festlegen, dass der zeitliche Abstand zwischen zwei Punkten
in der Trajektorie konstant bleibt. Somit können wir wenigsten eine grobe Vorstellung
der Zeitentwicklung des Systems erhalten, da Punkte mit größerem Abstand im Konfigurationsraum
schneller durchlaufen werden. Das können wir erreichen, indem
wir das Argument dense_output=True an die Funktion solve_ivp übergeben.
Damit wird das Attribut sol des Rückgabewerts resein
scipy.integrate.OdeSolution-Objekt
sein, was wie eine Funktion behandelt werden kann.
res = solve_ivp(
dydx,
(T0, TMAX),
C0,
method='Radau',
max_step=MAXSTEP,
dense_output=True,
)
x_plot = np.linspace(T0, TMAX, 5000)
y_plot = res.sol(x_plot)
Hier definieren wir nach dem Lösen des AWPs ein gleichmäßiges Grid mit
np.linspace,
wobei wir 5000 gleichmäßig verteilte Punkte zwischen T0 und TMAX wählen.
Die Lösung des AWPs an diesen Punkten erhalten wir dann durch Aufrufen der
Funktion res.sol mit dem Grid als Argument.
Alternativ kann mit dem Argument t_eval eine Liste von Zeitpunkten an solve_ivp
übergeben werden, an denen die Lösung berechnet werden soll.
Nun können wir die Konfigurationsraumtrajektorie der Lösung mit den zeitlich gleichmäßig verteilten Punkten plotten:
c_x, c_y, c_z = y_plot * 1000.0 # convert to mM
fig1, ax1 = plt.subplots(figsize=(6, 6), subplot_kw={'projection': '3d'})
ax1.scatter(c_x, c_y, c_z, s=10, alpha=0.1)
ax1.set_xlabel('[HBrO2] / mM')
ax1.set_ylabel('[Br-] / mM')
ax1.set_zlabel('2 [Ce4+] / mM')
ax1.set_xlim(0.0, 0.4)
ax1.set_ylim(0.0, 1.0)
ax1.set_zlim(0.0, 1.8)
fig1.tight_layout(rect=[0, 0, 0.95, 1.00])
plt.show()
Da der Konfigurationsraum dreidimensional ist, müssen wir beim Aufrufen der
Funktion plt.subplots das Argument subplot_kw={'projection': '3d'}
übergeben. Wir verwenden hier die Methode scatter anstatt von plot, um
die Punkte einzeln darzustellen. Mit dem Argument
s kann die Größe der Punkte eingestellt werden und mit alpha
die Transparenz, wobei wir alpha=0.1 (d.h. 10%) gewählt haben.
Die Methode
tight_layout
hat leider Schwierigkeiten mit 3D-Plots, weshalb wir hier den
gewünschten Bereich des Plots mit dem Argument rect=[0, 0, 0.95, 1.00]
manuell angepasst haben.
Das Ergebnis sollte wie folgt aussehen:
Durch die Einstellung alpha=0.1 können wir jetzt erkennen, welche Bereiche
des Konfigurationsraums mit welcher Frequenz besucht werden.
Am Anfang der Reaktion ist nur vorhanden (hintere Ecke). Danach nimmt seine
Konzentration ab, während die Konzentrationen der anderen Spezies erstmal auf einem Niveau nahe Null bleiben.
Da dieses Ereignis nur einmal stattfindet, sind die Punkte auch nur sehr
schwach sichtbar. Danach beginnt die Oszillation des Systems. Auch hier kann
man anhand der Farbstärke erkennen, dass die Änderung von (oberer Bogen)
schneller verläuft als die von (rechter Bogen).
Der Konfigurationsraum allein ist allerdings nicht ausreichend für eine vollständige Beschreibung des Systems. Wir wissen z.B. bei einer gegebenen -Konzentration nicht, ob diese gerade steigt oder fällt, d.h. in welcher Richtung das System sich auf der geschlossenen Kurve bewegt. Um das zu bestimmen, benötigen wir zusätzlich die “Geschwindigkeiten” oder “Impulse” der Koordinaten. Ein Raum, der sowohl die Koordinaten als auch die Geschwindigkeiten enthält, wird als Phasenraum bezeichnet.
Der Phasenraum des Oregonator-Modells ist also sechsdimensional, was über
die Grenzen des menschlichen Vorstellungsvermögens hinausgeht. Deshalb
plotten wir hier einen zweidimensionalen Schnitt durch den Phasenraum, indem wir nur
die Konzentrationen von und ihre Ableitungen zeigen. Auch hier verwenden wir
die gleichmäßig ausgewertete Lösung und berechnen die Ableitung mit der
Funktion dydx:
dzdt = dydx(x_plot, y_plot)[2] * 1000.0 # convert to mM/s
fig2, ax2 = plt.subplots(figsize=(6, 6))
ax2.scatter(c_z, dzdt, c='tab:green', alpha=0.1)
ax2.set_xlabel('2 [Ce4+] / mM')
ax2.set_ylabel('(2 d[Ce4+] / dt) / (mM / s)')
fig2.tight_layout()
plt.show()
Hier plotten wir die Ableitung dzdt gegen die Konzentration c_z, ebenfalls
mit der Methode scatter und dem Argument alpha=0.1. Das Diagramm sollte
wie folgt aussehen:
Die Trajektorie startet hier im Ursprung, verläuft dann im Uhrzeigersinn, also sowohl die Konzentration als auch ihre Ableitung steigen zunächst. Dann wird der Punkt der maximalen Zunahme erreicht, während die Konzentration weiter steigt. Zu einem späteren Punkt wird die Ableitung schließlich negativ und die Konzentration nimmt leicht ab. Besonders auffällig ist das letzte Stück der Trajektorie: Die Konzentration nimmt stetig ab, während die Ableitung von stark negativ zu null wird. Dieser Bereich im Phasenraum ähnelt sehr stark einer Graden, was einer Kinetik erster Ordnung entspricht. In diesem Bereich gilt , was einen exponentiellen Zerfall darstellt. Die intensivste Färbung in diesem Plot ist um den Ursprung, was bedeutet, dass für die meisten Zeitpunkte die Konzentration von und ihre Ableitung sehr klein sind.
Zoomen Sie in den interaktiven Plot des Phasenraums hinein, um die Details der Trajektorie besser zu erkennen. Nehmen Sie dazu das Diagramm des zeitlichen Verlaufes der Konzentration als Vergleich und versuchen Sie, die Merkmale in der Phasenraumtrajektorie wiederzuerkennen.
Übung
Aufgabe 2.2: Lösen des klassischen harmonischen Oszillators mit Runge-Kutta-Verfahren
Das Euler-Verfahren liefert für die Bewegungsgleichung des harmonischen Oszillators zwar eine stabile Lösung, allerdings ist sie nicht besonders genau. Wir könnten an dieser Stelle wieder die Schrittweite verkleinern, um die Genauigkeit zu erhöhen, jedoch wollen nun testen, wie sich das klassische Runge-Kutta-Verfahren (RK4) im Vergleich zum Euler-Verfahren schlägt.
Implementieren Sie das klassische Runge-Kutta-Verfahren 4. Ordnung anhand des folgenden Butcher-Tableaus und lösen Sie das System von Differentialgleichungen des klassischen harmonischen Oszillators aus der vorherigen Aufgabe mit den Anfangsbedingungen , , und einer Schrittweite von für . Plotten Sie die numerische Lösung und vergleichen Sie sie mit der Lösung des Euler-Verfahrens, sowie der analytischen Lösung.
Finite-Differenzen-Verfahren
Die finite-Differenzen-Methode ist eine weitere Klasse an numerischen Verfahren neben Runge-Kutta-Verfahren, die zur Lösung von gewöhnlichen Differentialgleichungen eingesetzt werden kann. Auch hier wird der Definitionsbereich der Funktion in diskrete Punkte unterteilt, allerdings liegt der Fokus bei diesem Verfahren nicht auf der Berechnung von Funktionswerten, sondern auf der Approximation der Ableitungsoperatoren.
Theoretische Grundlagen
Bei der Implementierung des Gradientenverfahrens haben wir bereits in Gl. (1.11) eine Formel für die finite Differenz verwendet. Diese Formel stellt ein Spezialfall der allgemeinen finite Differenz dar und fiel damals mehr oder weniger vom Himmel. Hier bemühen wir uns, ein allgemeines Rezept für die Herleitung solcher Formeln zu finden.
Finite-Differenzen-Approximationen
Gehen wir davon aus, dass wir die Ableitung einer Funktion am Punkt aus den Funktionswerten in der Umgebung von , wie z.B. , und (nährungsweise) berechnen wollen. Demnach muss gelten, wobei wir die Koeffizienten , und bestimmen wollen. Wir entwickeln die rechte Seite in eine Taylor-Reihe um : Die Idee ist nun, dass falls die rechte Seite gleich der linken Seite (also ) sein soll, auch die Koeffizienten vor den Funktionswerten, bzw. den Ableitungen, auf beiden Seiten gleich sein müssen. Da wir hier drei unbekannte Koeffizienten haben, benötigen wir drei Gleichungen, weshalb die Taylor-Reihe bis zur zweiten Ordnung entwickelt wurde. Vergleichen wir die Koeffizienten auf beiden Seiten, erhalten wir das folgende lineare Gleichungssystem: Man könnte dieses Gleichungssystem mit dem Gauß-Algorithmus lösen, bei nur drei Gleichungen und Unbekannten geht es auch durch einfaches Umformen und Einsetzen. Als Ergebnis erhalten wir die Koeffizienten , und . Nach Einsetzen in die obige Formel erhalten wir was identisch zu Gl. (1.11) ist.
Diese Finite-Differenzen-Formel wird als symmetrische Differenz zweiter Ordnung bezeichnet, da sie die Ableitung der Funktion an der Stelle symmetrisch aus den Funktionswerten und berechnet und dabei die Entwicklung der Taylor-Reihe bis zur zweiten Ordnung verwendet.
Mit den Stützpunkten und bzw. und kann man auf gleiche Weise die Forwärtsdifferenzen und die Rückwärtsdifferenzen herleiten. Diese beiden Formeln berücksichtigen allerdings nur die erste Ordung.
Gemäß dieses Verfahrens können wir auch die Ableitungen höherer Ordnungen mithilfe der Funktionswerte an beliebigen Stellen des Grid bis zur beliebigen Ordnung approximieren.
Matrixdarstellung des Differentialoperators
Wenn wir eine Funktion auf einem Gitter mit diskretisieren, können wir die Funktion als einen Vektor mit (näherungsweise) darstellen. Wollen wir den Funktionswert an einem beliebigen Gridpunkt berechnen, können wir das als Skalarprodukt des Vektors mit dem Basisvektor darstellen, wobei ein Vektor mit an der Stelle und an allen anderen Stellen ist. Das ergibt mit dem Kroenecker-Delta . Schreiben wir die Vektorkomponenten aus, ist das Skalarprodukt durch gegeben, wobei die Indizes der Einträge in explizit geschrieben wurden.
Auf diese Weise können wir als darstellen, mit dem Hilfsvektor
Die Ableitung an der Stelle kann gemäß Gl. (2.17) also als geschrieben werden. Da wir die Ableitung an allen Gridpunkten berechnen wollen, setzen wir das Muster fort und erhalten eine Matrixgleichung mit und
Diese Darstellung des Differentialoperators mit symmetrischen Finite-Differenzen zweiter Ordnung ist also eine Tridiagonalmatrix mit auf der Hauptdiagonale und auf den Nebendiagonalen.
Wie sieht es mit der Darstellung der zweiten oder höheren Ableitungen aus? Natürlich könnte man eine Differentialgleichung höherer Ordnung als ein System von Differentialgleichungen erster Ordnung auffassen und die Finite-Differenzen-Methode für vektorwertige Funktionen anpassen. Aber hier können wir die entsprechende Matrix auf einem direkten Weg konstruieren.
Dazu zeigen wir zunächst, wie man es nicht machen sollte: Die Form des Operators für die zweite Ableitung erinnert auf den ersten Blick an das “Quadrat” des Ableitungsoperators . Sollten wir tatsächlich die Matrix quadrieren, erhalten wir etwas, was auf den ersten Blick nicht falsch aussieht:
Diese Gleichung wäre dann korrekt, wenn wirklich die Ableitung von wäre. Das ist hier aber nicht der Fall, weil wir durch die Diskretisierung eine endliche Auflösung haben. Das Anwenden des Operators auf würde daher den Fehler der ersten Ableitung verstärken.
Benutzen Sie im Allgemeinen nicht die Matrix als die Darstellung des Operators der -ten Ableitung!
Jetzt zeigen wir, wie es richtig gemacht wird. Auch hier fassen wir zunächst die zweite Ableitung als die Ableitung der ersten Ableitung auf: wobei wir für die Rückwärtsdifferenzen (2.19) und für die Vorwärtsdifferenzen (2.18) benutzt haben. Diese Herleitung lässt sich leicht auf die -te Ableitung verallgemeinern.
Mit dieser Formel können wir die Matrixdarstellung des Operators für die zweite Ableitung, hier als bezeichnet, konstruieren: Diese Matrix ist wieder eine Tridiagonalmatrix, aber mit auf der Hauptdiagonale und auf den Nebendiagonalen.
Finite-Differenzen-Verfahren
Wir haben nun die Matrixdarstellung des Differentialoperators für die ersten und zweiten Ableitungen. In einer Differentialgleichung tauchen neben der gesuchten Funktion und ihren Ableitungen allerdings auch Funktionen von auf. Wir benötigen also eine Darstellung für solche Funktionen. Eine Besonderheit hierbei ist, dass es es dafür zwei Darstellungsvarianten gibt, welche man gemäß dem Kontext wählen muss.
Fungiert eine Funktion von als eine Inhomogenität (vgl. Gl. (2.1)), also als ein alleinstehender Term ohne oder ihre Ableitungen, wird sie genau so wie bei diskretisiert und als ein Vektor mit dargestellt. Wird die Funktion aber mit oder ihren Ableitungen multipliziert, dient sie als eine Koeffizientenfunktion , und muss als eine Matrix darstellt werden. Da die Multiplikation zweier Funktionen elementweise erfolgt, nimmt die Darstellungsmatrix Diagonalform an, wobei die Diagonalelemente die diskretisierten Funktionswerte von sind, also .
Nun kennen wir die (approximative) Darstellung aller Elemente einer DGL und können eine beliebige lineare Differentialgleichung (vgl. Gl. (2.1)) in eine Matrixgleichung umwandeln: Hier dient als die Matrixdarstellung der Koeffizientenfunktion und wir können in der obigen Gleichung haben wir alle lineare Operatoren zu einem Operator zusammengefasst. Die Lösung des linearen Gleichungssystems liefert demnach die diskretisierte Funktion .
Tatsächlich lassen sich auch nichtlineare DGLs mit Finiten-Differenzen-Operatoren ausdrücken. Das resultierte Gleichungssystem ist allerdings ein nichtlineares Gleichungssystem, welches nicht direkt mit Methoden der linearen Algebra gelöst werden kann.
Zwischen dem Lösen des linearen Gleichungssystems und dem Lösen einer Differentialgleichung gibt es einen entscheidenden Unterschied: Der Anfangswert. Während bei der Lösung der DGLs eine Anfangs- oder Randbedingung benötigt wird, um eine spezielle Lösung zu erhalten, gibt es eine solche Bedingung bei einem linearen Gleichungssystem nicht. Wie sollen wir dann die Anfangsbedingungen der DGL im Rahmen der diskretisierten Version berücksichtigen?
Betrachten wir dazu zunächst die Matrixdarstellung des Differentialoperators in Gl. (2.20), insbesondere die erste Zeile . Multipliziert mit besagt diese Zeile Gemäß Gl. (2.17) sollte aber doch gelten. Damit beide Gleichungen übereinstimmen, muss gelten. Die letzte Zeile von liefert wiederum die Bedingung .
Durch die Konstruktion der Finite-Differenzen-Operatoren als Matrizen werden die Randbedingungen also implizit festgelegt. Das Finiten-Differenzen-Verfahren ist deshalb eher geeignet für Randwertprobleme als für Anfangswertprobleme.
Neben der Dirichlet-Randbedingung in unserem Fall, also dass die Funktionswerte außerhalb des Grids Null sein müssen, können noch andere Randbedingungen, wie z.B. periodische Randbedingungen oder Neumann-Randbedingungen mit dem Finite-Differenzen-Verfahren berücksichtigt werden. Diese werden wir aber hier nicht weiter behandeln.
Implementierung
Wir wollen nun das Finite-Differenzen-Verfahren am Beispiel der Schrödingergleichung für den harmonischen Oszillator implementieren.
Die Schrödingergleichung in atomaren Einheiten lautet und ist eine lineare DGL zweiter Ordung mit Randbedingungen . Für die Implementierung eignet sich also die Darstellungsmatrix in Gl. (2.21).
Nach dem Importieren der benötigten Libraries
import numpy as np
import matplotlib.pyplot as plt
können wir den Differentialoperator wie folgt definieren:
def generate_d2_naive(n: int, h: float = 1.0) -> np.ndarray:
d2 = np.zeros((n, n))
for i in range(0, n):
for j in range(0, n):
if i == j:
d2[i, j] = -2
elif i == j - 1 or i == j + 1:
d2[i, j] = 1
return d2 / h**2
Während diese einfache Implementierung zwar korrekt ist, ist sie allerdings nicht sehr
effizient, insbesondere wenn die Anzahl der Gridpunkte n groß ist.
Eine effizientere Implementierung kann mithilfe der Funktion
np.diag_indices
erzielt werden:
def generate_d2(n: int, h: float = 1.0) -> np.ndarray:
d2 = np.zeros((n, n))
rows, cols = np.diag_indices(n)
d2[rows, cols] = -2
d2[rows[:-1], cols[1:]] = 1
d2[rows[1:], cols[:-1]] = 1
return d2 / h**2
Nach der Initialisierung einer Nullmatrix haben wir uns mit Hilfe der Funktion np.diag_indices
die Indizes der Hauptdiagonalen ausgeben lassen. Damit können die entsprechenden Elemente
des Arrays d2 auf gesetzt werden.
Durch Verschieben der Indizes um können wir die Einträge der Nebendiagonalen ansprechen
und die entsprechenden Elemente auf setzen. Der Verzicht auf
Schleifen beschleunigt die Berechnung erheblich.
Wie wir schon in der Einführung erwähnt haben, ist Python im Vergleich
zu kompilierten Sprachen eine eher langsame Sprache. Deshalb sollte man,
wenn Effizienz von entscheidender Bedeutung ist, Schleifen vermeiden und stattdessen
Funktionen aus der Bibliothek numpy benutzen.
Bei Anwendungen, bei denen eine große Anzahl von Gitterpunkten benötigt wird, kann die Verwendung von dünnbesetzten Matrizen (engl. sparse matrices) von Vorteil sein. Da wir in diesem Beispiel nur eine moderate Anzahl von Gitterpunkten benötigen, werden wir die Implementierung mit einer normalen Matrix durchführen.
Anmerkung zu dünnbesetzten Matrizen
Bei großen durch feinere oder auch mehrdimensionale Grids kann
die Finite-Differenzen-Matrix sehr groß werden. Allerdings ist der
Großteil der Elemente dieser Matrix Null. Solche Matrizen werden als
dünnbesetzte Matrizen
(engl. sparse matrices) bezeichnet und können durch spezielle Algorithmen
effizienter gespeichert und verarbeitet werden. Die
Fininte-Differenzen-Matrix hat sogar nur Einträge auf der Haupt- und
einigen Nebendiagonalen und wird deshalb auch als
Bandmatrix bezeichnet,
was eine verallgemeinerung der Tridiagonalmatrix ist. Bandmatrizen
können mit Hilfe der Funktion
scipy.sparse.diags_array
als dünnbesetzte Matrizen implementiert werden.
Dann können Methoden aus dem Untermodul
scipy.sparse.linalg
eingesetzt werden, um lineare Gleichungssysteme zu lösen oder Eigenwerte
und Eigenvektoren zu berechnen.
Möchte man nur die Eigenwerte und Eigenvektoren von Bandmatrizen berechnen,
kann die Funktion
scipy.linalg.eigh_banded
oder
scipy.linalg.eigh_tridiagonal
eingesetz werden, die als Argument lediglich die besetzten Diagonalen der Bandmatrix erwartet.
Wir konstruieren anschließend den Hamiltonoperator für den harmonischen Oszillator als Matrix:
def build_hamiltonian(n: int, x: np.ndarray, k: float = 1.0) -> np.ndarray:
h = x[1] - x[0]
d2 = generate_d2(n, h)
vx = np.diag(0.5 * k * x**2)
l_mat = -0.5 * d2 + vx
return l_mat
Hier haben wir zuerst die -Matrix mit der Funktion generate_d2
erzeugt und dann die Potentialfunktion als eine
Diagonalmatrix mit np.diag erstellt. Die Summe
ergibt dann den Hamiltonoperator,
bzw. den linearen Operator (l_mat).
Damit lautet die Schrödingergleichung in diskreter Form
Man erkennt an dieser Stelle leicht, dass es sich um eine Matrix-Eigenwertgleichung handelt.
Wir verwenden deshalb die Funktion
np.linalg.eigh, um die Eigenwerte und Eigenvektoren des Hamiltonoperators zu
berechnen.
Wir setzen dafür zunächst und wählen ein Grid von -5 bis 5 mit 512 Punkten:
K = 1.0
NX = 512
X_ARRAY = np.linspace(-5, 5, NX)
Danach wird der Hamiltonoperator erzeugt und die Eigenwerte und Eigenvektoren berechnet:
hamiltonian = build_hamiltonian(NX, X_ARRAY, K)
assert np.allclose(hamiltonian, hamiltonian.T)
e, v = np.linalg.eigh(hamiltonian)
Da die Funktion np.linalg.eigh eine symmetrische Matrix erwartet, haben
wir mit assert und
np.allclose
überprüft, ob die Matrix hamiltonian identisch zu ihrer Transponierten ist.
Wir entnehmen nun die Energien und die zugehörigen Wellenfunktionen aus den ersten 20 Eigenvektoren und Eigenwerten.
NSTATES = 20
eigenenergies = e[:NSTATES]
eigenfunctions = v[:, :NSTATES] / np.sqrt(X_ARRAY[1] - X_ARRAY[0])
Allerdings sollten die Wellenfunktionen gemäß
normiert sein, was in der diskreten Form
entspricht. Die Eigenvektoren aus np.linalg.eigh sind aber gemäß
normiert. Deshalb teilen wir die Eigenvektoren durch , wobei wir
aus den ersten beiden Einträgen des Grids entnehmen,
um die normierten Wellenfunktionen zu erhalten.
Zuletzt wollen wir unsere Ergebnisse visualisieren. Wir wollen dabei die Eigenenergien
und die Wellenfunktionen nebeneinander plotten. Deshalb übergeben wir die folgenden Argumente
an die Funktion plt.subplots:
fig, axs = plt.subplots(1, 2, figsize=(8, 4))
Hier bedeuten die Argumente 1, 2, dass wir zwei separate Diagramme in einer Zeile und
zwei Spalten erzeugen möchten. Die Achsen-Objekte werden dann in der Liste axs gespeichert.
Danach plotten wir die numerischen sowie die analytischen Eigenenergien
des harmonischen Oszillators in dem ersten Plot mit axs[0]:
axs[0].plot(np.arange(NSTATES), eigenenergies, 'o',
label='numerical eigenenergies')
axs[0].plot(np.arange(NSTATES), np.sqrt(K) * (np.arange(NSTATES) + 0.5),
label='analytical eigenenergies')
axs[0].set_xlabel('state')
axs[0].set_ylabel('energy')
axs[0].legend()
Anschließend plotten wir in dem zweiten Diagramm (axs[1]) das harmonische
Potential sowie die ersten 5 numerischen Wellenfunktionen:
axs[1].plot(X_ARRAY, 0.5 * K * X_ARRAY**2, color='k', lw=2, label='potential')
for i in range(5):
axs[1].plot(X_ARRAY, eigenfunctions[:, i] + eigenenergies[i],
label=f'state {i}')
axs[1].set_xlabel('x')
axs[1].set_ylabel('energy')
axs[1].set_xlim(X_ARRAY[0], X_ARRAY[-1])
axs[1].set_ylim(-0.5, 9.5)
axs[1].legend()
Beim Plotten des Potentials haben wir das Argument lw=2 benutzt, um die
Linienbreite zu erhöhen.
Zuletzt formatieren wir den Plot und zeigen ihn an:
fig.tight_layout()
plt.show()
Das Diagramm sollte wie folgt aussehen:
Man erkennt, dass die numerischen Eigenenergien der ersten ca. 10 Zuständen sehr gut mit den analytischen Eigenenergien übereinstimmen. Die numerisch berechneten Wellenfunktionen der ersten fünf Zustände sehen zumindest auf den ersten Blick sinnvoll aus. Sollte man größere Genauigkeit für die höheren Zustände benötingen, muss das Grid feiner und auch größer gewählt werden, da die Wellenfunktionen einerseits mehr Oszillationen aufweisen und andererseits räumlich ausgedehnter sind.
Übung
Aufgabe 2.3: Lösen der Schrödingergleichung mit dem Schießverfahren
In der Vorlesung haben wir gesehen, dass die Randbedingungen der Schrödingergleichung des harmonischen Oszillators mit der Finite-Differenzen-Methode implizit erfüllt werden. Wir werden nun das sogennante Schießverfahren kennenlernen, mit welchem wir dieses Randwertproblem in ein Anfangswertproblem umwandeln können, um es mit den bekannten numerischen Methoden, wie den Runge-Kutta-Verfahren, zu lösen. Für eine allgemeine Differentialgleichung zweiter Ordnung mit den Randbedingungen und umfasst diese Methode, dass wir zunächst einen beliebigen Anfangswert wählen. Dann lösen wir die Differentialgleichung mit den Anfangsbedingungen und und überprüfen ob die Randbedingung erfüllt ist. Falls nicht, passen wir den Anfangswert an und wiederholen den Prozess, bis die Randbedingung erfüllt ist. Der Name des Verfahrens leitet sich von der Analogie zum Schießen einer Kanone ab, bei der der Abschusswinkel so lange angepasst wird, bis das Ziel getroffen wird.
Wir wollten nun das Schießverfahren verwenden, um die Wellenfunktion und Energie eines Teilchens in einem endlichen Potentialtopf der Länge zu bestimmen. Die Schrödingergleichung für dieses Problem lautet mit dem Potential für und ansonsten, sowie den Randbedingungen und . Im Rahmen eines Anfangswertproblems können wir die Wellenfunktion nur bis auf eine Normierungskonstante bestimmen, weshalb die konkrete Wahl des Anfangswertes irrelevant ist. Wir wissen allerdings, dass die Schrödingergleichung unendlich viele spezielle Lösungen hat, die jeweils durch die Quantenzahl und die entsprechender Energie charakterisiert sind. Die Energie ist demnach ein Parameter, den wir im Rahmen des Schießverfahrens solange anpassen können, bis die Randbedingung erfüllt ist. Normalerweise formuliert man dieses Ziel als ein Nullstellenproblem, welches mit numerischen Methoden gelöst werden kann. Da wir aber mehr als eine Lösung finden wollen, verwenden wir ein leicht abgewandeltes Verfahren.
(a) Implementieren des Schießverfahrens für das Teilchen im Kasten
Implementieren Sie das Schießverfahren, um die Schrödingergleichung für ein Teilchen in einem endlichen Potentialtopf der Länge zu lösen. Gehen Sie dazu wie folgt vor:
-
Überführen Sie die Schrödingergleichung in ein System von Differentialgleichungen erster Ordnung mit Hilfe der Substitution und implementieren Sie die Ableitungen der Funktionen und .
-
Lösen Sie das Anfangswertproblem mit einem Startwert und den Anfangsbedingungen und mit Hilfe der Funktion
solve_ivpüber dem Intervall . Erhöhen Sie dann iterativ die Energie in kleinen Schritten von und lösen Sie das Anfangswertproblem in jedem Schritt. -
Überprüfen Sie in jedem Schritt, ob die Randbedingung erfüllt ist, indem Sie den Absolutwert von mit einer Toleranz von vergleichen. Ist dies erfüllt, speichern Sie jeweils die Energie und die Wellenfunktion in einer Liste und führen Sie das Verfahren fort.
Bestimmen Sie mit diesem Verfahren die ersten fünf Energieniveaus und plotten Sie die zugehörigen Wellenfunktionen.
Sollte der Absolutwert von innerhalb ihrer gewählten Toleranz liegen, bietet es sich an, die Energie anschließend in einem größeren Schritt, z.B. , zu erhöhen. Ansonsten könnten Sie im darauffolgenden Schritt die gleiche Wellenfunktion erneut erhalten. Ändern Sie die Parameter der Schrittweite und Toleranz, um die Genauigkeit und Geschwindigkeit des Verfahrens zu beeinflussen.
(b) Implementieren des Schießverfahrens für das Teilchen im Stufenpotential
Erweitern Sie nun Ihre Implementierung, um die Wellenfunktionen und Energien des Teilchens in einem
rechtsseitigen Stufenpotential der Höhe und Breite zu bestimmen. Dazu müssen Sie
lediglich das Potential
in der Berechnung von berücksichtigen,
was Sie mit Hilfe einer if-Bedingung erreichen können.
Bestimmen Sie erneut die ersten fünf Energieniveaus und plotten Sie die zugehörigen Wellenfunktionen. Was beobachten Sie?
Fourier-Analyse
Die Fourier-Analyse, auch bekannt als klassische harmonische Analyse, ist ein mathematisches Verfahren, um zeitliche Signale in ihre Frequenzanteile zu zerlegen. Sie ist nach dem französischen Mathematiker Jean Baptiste Joseph Fourier benannt. Die Möglichkeit, beliebige Signale in ihre Frequenzanteile zu zerlegen, bietet eine Vielzahl von Anwendungen, die von der Naturwissenschaft und Mathematik über die Ingenieurwissenschaft und Wirtschaftswissenschaft bis hin zur Medizin und Musik reichen.
Obwohl die Fourier-Analyse ursprünglich für zeitliche Signale entwickelt wurde, kann sie auch auf andere Arten von Signalen angewendet werden, welches das Anwendungsgebiet noch weiter ausdehnt: die Konversion der Röntgenbeugungsmuster in die Struktur eines Kristalls, die Berechnung des Infrarot-Spektrums aus dem Interferogramm, die Beziehung zwischen dem Ort und dem Impuls eines Teilchens in der Quantenmechanik, um nur einige Beispiele aus der Chemie zu nennen. Es gibt fast unzählige weitere Anwendungen in verschiedesten Bereichen. Damit dieses Skript nicht zu lang wird, werden wir uns in diesem Kapitel auf einige wenige chemische Anwendungen beschränken.
Es sei noch angemerkt, dass die Fourier-Analyse auch für die Lösung einiger Arten von Differentialgleichungen verwendet werden kann, um eine Verknüpfung zum vorherigen Kapitel herzustellen.
Fourier-Reihen
Sei eine komplexwertige, periodische Funktion mit Periodenlänge (kurz: -periodische Funktion): Für diese Funktion gilt dann für alle . Weiterhin nehmen wir an, dass , d.h. ist quadratintegrierbar auf dem Intervall und damit auf jedem Intervall der Länge .
Wir definieren den Durchschnitt dieser Funktion (über eine Periode) als Die Integrationsgrenzen sind aufgrund der Periodizität der Funktion beliebig wählbar, solange die Länge des Integrationsintervalls beträgt. Nun definieren wir den oszillierenden Anteil der Funktion als , womit gilt. Der Mittelwert der oszillierenden Funktion ist offensichtlich null.
Nun betrachten wir die Summe aus beliebigen periodischen Funktionen mit verschwindendem Mittelwert, die wir hier als Basisfunktionen bezeichnen. Aufgrund der -Periodizität von können die Basisfunktionen nur Periodenlängen von haben, wobei eine natürliche Zahl ist. Anders ausgedrückt, können die Basisfunktionen nur Winkelfrequenzen von besitzen.
Eine Familie solcher Basisfunktionen ist die komplexe Exponentialfunktion . Diese Funktionen sind -periodisch und haben den Mittelwert Null: Da beide Eigenschaften (-Periodizität und Mittelwert Null) unter skalarer Multiplikation und Addition erhalten bleiben, erfüllen alle Linearkombinationen von diese Eigenschaften ebenfalls.
Daher nehmen wir gewagt an, dass durch eine solche Linearkombination ausgedrückt werden kann: Zusammen mit dem konstanten Anteil ergibt sich dann wobei . Diese Reihe wird als Fourier-Reihe der Funktion bezeichnet. Carleson und Hunt konnten zeigen, dass diese Reihendarstellung für alle -Funktionen möglich ist.
Wie können wir nun die Koeffizienten bestimmen? Wenn wir den einzig bekannten Koeffizienten betrachten, stellen wir fest, dass wir diesen durch Mittelung der Funktion, d.h. Integration, erhalten haben. Der oszillierende Anteil hebt sich bei der Mittelung auf, so dass nur der konstante Anteil übrig bleibt. Es wäre doch nützlich, wenn es eine Art von “Mittelung” auch für die anderen Koeffizienten gäbe.
Was passiert, wenn wir die Funktion zuerst mit multiplizieren und anschließend mitteln? Dann wird der -Anteil konstant und die anderen Anteile, insb. der konstante Anteil, oszillieren. Die Mittelwertbildung würde jetzt also den Koeffizienten liefern. Auf diese Weise können wir alle Koeffizienten mit bestimmen.
Wir sind also jetzt in der Lage, jede beliebige -periodische -Funktion in ihre (diskrete) Frequenzanteile zu zerlegen. Die Signale, die wir in der Praxis messen, sind jedoch meistens nicht periodisch. Um solche Signale analysieren zu können, führen wir im nächsten Abschnitt die Fourier-Transformation ein.
Fourier-Transformation
Bevor wir auf die Frequenzzerlegung für aperiodische Signale eingehen, schauen wir uns die Fourier-Koeffizienten in Gl. (3.2) genauer an. Diese Koeffizienten geben an, wie stark die Frequenz in der Funktion enthalten ist. Wir können das als eine Abbildung verstehen: oder explizit mit der Winkelfrequenz: wobei .
Eine aperiodische Funktion kann als eine periodische Funktion mit einer unendlich langen Periode betrachtet werden. Unser Ansatz ist demnach, in Gl. (3.2) zu setzen. Wir erhalten wobei wir den Vorfaktor ignoriert haben, da dieser für gegen unendlich explodiert und wir somit keine sinnvolle Definition für die Koeffizienten erhalten. Um den Unterschied dieser Größe zu den Fourier-Koeffizienten zu verdeutlichen, bezeichnen wir diese nicht mehr als , sondern als .
Die Grenzwertbildung hat auch eine Auswirkung auf die Winkelfrequenz : Durch wird der Abstand zwischen den Vielfachen der Grundfrequenz infinitesimal klein, sodass wir von einer kontinuierlichen Frequenzverteilung sprechen können. Deshalb verwenden wir die Notation statt . Das liefert uns die Fourier-Transformation mit Rückwärtsnormierung:
Um die wenig intuitive Bezeichnung “Rückwärtsnormierung” zu verstehen, müssen wir zuerst die Fourier-Synthese, die Rekonstruktion der Funktion aus den Frequenzanteilen, diskutieren. Für eine periodische Ausgangsfunktion ist die Fourier-Synthese durch die Fourier-Reihe in Gl. (3.1) gegeben. Da wir für eine aperiodische Funktion ein kontinuierliches Frequenzspektrum haben, müssen wir die Summe durch ein Integral ersetzen, also etwa wie wobei die rekonstruierte Funktion ist. Setzen wir nun aus Gl. (3.3) ein, erhalten wir wobei wir unbekümmert die Reihenfolge der Integrationen vertauscht haben.
Das innere Integral sieht sehr ungewohnt aus. Nach einer kurzen Rechnung erhalten wir mit der Dirac’schen Deltafunktion .
Rechnung für Interessierten
Wir betrachten das Integral mit . Wir können die Exponentialfunktionen zusammenfassen und den Exponenten quadratisch ergänzen: Damit ist das obige Integral ein verschobenes Gauss-Integral, dessen analytische Lösung bekannt ist:
Da wir sowieso von bis integrieren, spielt die Verschiebung keine Rolle und wir erhalten das gleiche Ergebnis wie beim unverschobenen Gauss-Integral. Das Ergebnis ist eine Funktion in Abhängigkeit von , die wir nennen.
Im Folgenden betrachten wir einige Eigenschaften der Funktion , beginnend mit dem Integral: Das Integral dieser Funktion über die reellen Zahlen ist also normiert, unabhängig von der Wahl von .
Wie sieht es dann mit den Funktionswerten aus, wenn wir die Grenzwertbildung durchführen? Die Funktion ist eine Gaußfunktion, dessen Definitionsbereich mit und dessen Zielmenge mit skaliert ist. Für wird die Funktion also immer schmaler und höher, was zu der “Grenzfunktion” führt. Obwohl der Funktionswert bei unendlich ist, haben wir vorher berechnet, dass der Flächeninhalt unter der Kurve ist. Diese beiden Eigenschaften definieren gerade die -Funktion , die wir im Folgenden verwenden werden. Genau genommen definieren diese beiden Eigenschaften keine Funktion, da unklar bleibt, was bedeutet. Deshalb sollte man eher von einer -Distribution sprechen.
Die Grenzwertbildung führt ebenfalls dazu, dass der zusätzliche Faktor im obigen Integral zu wird. Damit erhalten wir:
Diese “Funktion” hat die Eigenschaft, wenn das Integral über ihr Produkt mit einer beliebigen Funktion gebildet wird, diese Funktion ausgewertet an der Stelle liefert, also
Es gilt insgesamt Die rekonstrurierte Funktion ist also um den Faktor skaliert. Damit wir tatächlich die ursprüngliche Funktion rekonstruieren können, müssen wir während dieser Rekonstruktion (rückwärts Fourier-Transformation oder inverse Fourier-Transformation) die Funktion mit dem Normierungsfaktor multiplizieren: Diese Gleichung stellt nun die inverse Fourier-Transformation mit Rückwärtsnormierung dar und erklärt die Bezeichnung der Gl. (3.3), da die Normierung bei der Rücktransformation berücksichtigt wird. Wir können analog die Fourier-Transformation mit Vorwärtsnormierung definieren: wobei die Normierung bei der Vorwärts-Transformation berücksichtigt wird. Die entsprechende inverse Transformation enthält demnach keinen Normierungsfaktor. Es gibt auch noch die Fourier-Transformation mit symmetrischer Normierung: wobei der Normierungsfaktor auf die Vorwärts- und Rückwärts-Transformation aufgeteilt wird.
Man findet in Lehrbüchern und wissenschaftlichen Arbeiten alle drei Definitionen der Fourier-Transformation. Alle diese Definitionen sind gleichwertig und korrekt, sofern die zugehörige inverse Transformation entsprechend definiert wird. Allerdings hat die symmetrische Normierung den Vorteil, dass die Transformation eine unitäre Abbildung darstellt, was Rechnungen in manchen Gebieten, wie z.B. in der Quantenmechanik, vereinfachen kann.
Die Fourier-Transformation ist aus mathematischer Sicht schön und gut, aber ein Computer kann mit einem kontinuierlichen Signal nicht viel anfangen, ganz zu schweigen von kontinuierlichen Frequenzspektren. Deshalb führt uns der nächste Abschnitt zur diskreten Fourier-Transformation, die wir in der Praxis verwenden können.
Diskrete Fourier-Transformation
Wenn wir die Fourier-Transformation in Gl. (3.3) (oder auch mit anderer Normierung) auf ein Signal am Computer anwenden wollen, gibt es zwei Probleme: Einerseits ist die Funktion an (überabzählbar) unendlich vielen Stellen definiert und andererseits besteht das Integral von bis . Der Computer kann aber nur endlich viele Werte berechnen und speichern. Deshalb müssen wir die Funktion diskretisieren und diese nur über ein beschränktes Intervall integrieren. Dadurch erhalten wir die diskrete Fourier-Transformation (DFT).
Theoretische Grundlagen
Wir wählen zunächst ein gleichmäßiges Zeitgrid mit Abstand , also . Möchte man die Funktion an einem anderen gleichmäßigem Grid evaluieren, kann das durch Verschiebung und Skalierung der Funktion realisiert werden. Damit wird Gl. (3.3) zu mit , wobei wir die Ersetzung vorgenommen haben.
Weil wir die Funktion nur im begrenzten Intervall kennen, ist die kleinstmögliche Frequenz (abgesehen von ) die Grundfrequenz . Damit gilt für . Die diskrete Fourier-Transformation mit Rückwärtsnormierung ist demnach durch gegeben.
Man kann sich leicht davon überzeugen, dass bei der Rücktransformation in diesem Fall ein Normierungsfaktor von notwendig ist, also Gl. (3.8) beschreibt die inverse diskrete Fourier-Transformation. In Analogie zu der kontinuierlichen Fourier-Transformation kann man hier auch Vorwärtsnormierung und symmetrische Normierung definieren, die wir hier nicht explizit aufführen werden. Da wir in diesem Kurs haupsächlich mit der Rückwärtsnormierung arbeiten werden, einigen wir uns darauf, dass ist, wenn nicht explizit anders angegeben.
Obwohl wir auf den ersten Blick vermuten, Kreisfrequenzen bis zu
analysieren zu können, bringt die Diskretisierung des ursprünglichen Signals
eine Schwierigkeit mit sich. Um dieses Problem zu verstehen, betrachten wir
das Signal , welches eine Kreisfrequenz
besitzt. Bei Punkten korrespondiert dies mit :
Obwohl das stetige Signal (blau) eine höhere Frequenz hat, kann diese durch
die sehr grobe Diskretisierung (orange) nicht dargestellt werden.
Das diskretisierte Signal suggeriert eine deutlich niedrigere Frequenz, welche
in diesem Fall ist.
Tatsächlich können Kreisfrequenzen, die größer sind als die halbe Abtastfrequenz (hier also etwa ), nicht mehr dargestellt werden. Die folgende Animation zeigt das diskretisierte Signal für :
Es kann gezeigt werden, dass abhängig von der Anzahl der Datenpunkte folgende Beziehung gilt:
Das bedeutet, dass die obere Hälfte des Spektrums eben nicht die höheren Kreisfrequenzen repräsentiert, sondern die negativen Frequenzen. Der Faktor in Gl. (3.9) berücksichtigt dabei die Skalierung des Zeitgrids im Fall .
Mit den Gleichungen (3.7) und (3.9) können wir die diskrete Fourier-Transformation nun implementieren.
Implementierung
Wir implementieren die diskrete Fourier-Transformation am Beispiel der Fourier-Transformations-Infrarot-Spektroskopie (FTIR-Spektroskopie). Ohne im Detail auf die Theorie der FTIR-Spektroskopie einzugehen, lässt sich sagen, dass das IR-Spektrum mit dieser Methode nicht durch die schrittweise Änderung der Wellenlänge des Lichts, welches durch die Probe geleitet wird, erhalten wird, sondern durch die Fourier-Transformation eines Interferogramms. Dieses erhält man, indem man Licht durch ein Interferometer schickt, wobei das Interferenzmuster der Lichtstrahlen gemessen werden, während ein beweglicher Spiegel die Weglänge der Strahlen verändert. Für das IR-Spektrum gilt also wobei das gemessene Interferogramm und das gewünschte IR-Spektrum ist. Das Symbol steht natürlich für die (diskrete oder kontinuierliche) Fourier-Transformation. Ein möglicher Vorfaktor ist hier nicht relevant.
Sie finden hier das Interferogramm der
Hintergrundmessung und der
Probemessung eines
FTIR-Spektrometers.
Der Anfang der Datei der Hintergrundmessung (ir_bg.txt) sieht wie folgt aus:
# dx intensity
-8191 0.000000000000
-8190 0.000000000000
-8189 0.000000000000
-8188 0.000000000000
-8187 0.000000000000
-8186 0.000000000000
-8185 0.000000000000
-8184 0.000000000000
-8183 0.000000000000
Die Datei enthält zwei Spalten, wobei die erste die Verschiebung, bzw. Anzahl der
Schritte angibt und die zweite der Intensität entspticht. Die Datei der Probemessung
(ir_spl.txt) ist analog aufgebaut. Bei dem verwendeten Spektrometer beträgt
die Schrittweite 0.3165 μm.
Zunächst importieren wir die benötigten Bibliotheken und definieren die
Schrittweite als Konstante XSTEP:
import numpy as np
import matplotlib.pyplot as plt
XSTEP = 0.3165e-6 # m
Anschließend importieren wir die Messdaten:
bg_dx, bg_int = np.loadtxt('ir_bg.txt', unpack=True)
spl_dx, spl_int = np.loadtxt('ir_spl.txt', unpack=True)
assert np.allclose(bg_dx, spl_dx)
Da wir die Hintergrundmessung von der Probemessung abziehen müssen,
sollten wir sicherstellen, dass die Verschiebungen des Interferometers dx in beiden
Messungen übereinstimmen, was mit dem assert-Befehl überprüft haben.
Wir können die Inteferogramme, sowie deren Differenz anschließend plotten:
int_x = spl_int - bg_int
fig1, [ax1, ax2] = plt.subplots(1, 2, figsize=(8, 4))
ax1.plot(bg_dx, bg_int, label='background')
ax1.plot(spl_dx, spl_int, label='sample')
ax1.set_xlabel('relative shift / step')
ax1.set_ylabel('intensity / arb. u.')
ax1.set_xlim(-200, 200)
ax1.legend()
ax2.plot(bg_dx, int_x, label='difference')
ax2.set_xlabel('relative shift / step')
ax2.set_ylabel('intensity / arb. u.')
ax2.set_xlim(-200, 200)
ax2.legend()
fig1.tight_layout()
plt.show()
Um die Details zu erkennen, haben wir den Bereich des Plots auf
bis Schritte beschränkt. Die resultierende Diagramme sehen wir hier:
Die Interferogramme der Hintergrund- und Probemessung sehen extrem ähnlich aus und unterscheiden sich in ihrer Intensität nur minimal. Das Differenzsignal, welches das Signal der reinen Probe darstellt, ist auf der recht Seite gezeigt und muss im Folgenden Fourier-transformiert werden:
nx = len(spl_dx)
int_nu = np.zeros(nx, dtype=complex)
n_array = np.arange(nx)
for k in range(0, nx):
int_nu[k] = np.sum(int_x * np.exp(-1j * 2 * np.pi * k / nx * n_array))
Dazu wurde zunächst das Nullarray
int_nu mit der Länge der Messdaten erstellt. Beachten Sie dabei, dass wir den
Datentyp complex verwenden müssen, da die Fourier-Transformation
komplexe Zahlen zurückgibt. Danach haben wir mit
np.arange
das Array erstellt, über welches wir iteriert haben, um gemäß
Gl. (3.7) die diskrete Fourier-Transformation zu berechnen.
Danach berechnen wir die “Frequenzen” gemäß Gl. (3.9). Hier sollten wir aber eine kleine Anpassung vornehmen: Da unser Ausgangssignal in Abhängigkeit der Verschiebung gemessen wurde und nicht der Zeit , entspricht die reziproke Größe der Wellenzahl , bzw. der Länge des Wellenvektors . Weil das IR-Spektrum konventionell in Abhängigkeit der Wellenzahl dargestellt wird, sollten wir den Vorfaktor in Gl. (3.9) durch ersetzen. Das führt zu der folgenden Implementierung:
x_grid = bg_dx * XSTEP
dx = x_grid[1] - x_grid[0]
if nx % 2 == 0:
nu_pos = (1.0 / (nx * dx)) * np.arange(0, nx // 2)
nu_neg = (1.0 / (nx * dx)) * np.arange(-nx // 2, 0)
else:
nu_pos = (1.0 / (nx * dx)) * np.arange(0, (nx - 1) // 2 + 1)
nu_neg = (1.0 / (nx * dx)) * np.arange(-(nx - 1) // 2, 0)
nu = np.concatenate((nu_pos, nu_neg))
Hier haben wir mit Hilfe der if-else-Anweisung die Fallunterscheidung zwischen
geradem und ungeradem nx berücksichtigt. Dabei wurden den positiven
und negativen Frequenzanteilen separat berechnet. Am Ende haben wir diese
mit der Funktion np.concatenate zusammengefügt.
Weil es angenehmer ist, mit monoton aufsteigenden Wellenzahlen zu arbeiten, sortieren wir im Anschluss der Fourier-Transformation die Wellenzahlen und das Spektrum:
sort_idx = np.argsort(nu)
nu = nu[sort_idx]
int_nu = int_nu[sort_idx]
Die Funktion np.argsort gibt dabei die Indizes der sortierten Werte zurück. Mit diesen Indizes können wir dann andere Arrays gemäß dieser Sortierung ebenfalls sortieren.
Wenn wir uns an die “Herleitung” der DFT erinnern, haben wir dabei angenommen,
dass die Funktion an den Stellen gesampelt wurde.
In unserem Fall startet die Verschiebung aber bei [μm]
und das Intervall zwischen den Schritten beträgt 0.3165 [μm].
Deshalb müssen wir die ursprüngliche Funktion skalieren und verschieben, um
die korrekte Fourier-Transformation zu erhalten. Dabei helfen uns die
folgenden Beziehungen:
die leicht nachzuprüfen sind, wobei . Die Gl.
(3.11)
besagt, dass eine Skalierung des Arguments der Funktion zu einer
Skalierung des Arguments und des Betrags der Fourier-Transformierten führt.
Während diese Beziehung den Vorfaktor in Gl. (3.9)
erklärt, können wir sie in unserem Fall ignorieren, da uns
die absolute Skalierung des Spektrums nicht interessiert.
Die Gl. (3.10) besagt, dass eine Verschiebung
im Zeitraum um zu einer Multiplikation der Fourier-Transformierten mit
dem Phasenfaktor führt. Weil die DFT die
Ausgangsfunktion ab auswertet, müssten wir sie um
[μm] verschieben, was in unserer
Implementierung in x_grid[0] gespeichert ist. Das führt zu der folgenden
Manipulation im Fourier-Raum:
int_nu *= np.exp(-1j * 2 * np.pi * nu * x_grid[0])
assert np.max(np.abs(int_nu.imag)) / np.max(np.abs(int_nu)) < 0.05
Hier haben wir den In-place-Operator *= verwendet, um die Werte von
int_nu direkt zu ändern. Es kann gezeigt werden, dass die
Fourier-Transformierte einer symmetrischen Funktion reell ist, was wir
hier mit dem assert-Befehl überprüfen. Dabei wird das Attribut imag
eines komplexen Arrays verwendet, um dessen Imaginärteil zu erhalten.
Eine letzte kosmetische Anpassung ist die Änderung der Einheit von
nu von zu :
nu /= 100 # cm^-1
Hier wurde der In-place-Operator /= verwendet, um die Werte von nu direkt
durch zu teilen.
Endlich können wir das IR-Spektrum plotten:
fig2, ax2 = plt.subplots(figsize=(8, 4))
ax2.plot(nu, np.abs(int_nu.real))
ax2.set_xlabel('wavenumber / cm⁻¹')
ax2.set_ylabel('intensity / arb. u.')
ax2.set_xlim(0, 4000)
fig2.tight_layout()
plt.show()
Dabei haben wir mit Hilfe des Attributs real den Realteil des Spektrums erhalten.
Außerdem haben wir den Plotbereich auf bis beschränkt.
Das Spektrum sieht wie folgt aus:
Wir erkennen deutlich die aromatische C-C-Streckschwingung bei etwa sowie aromatische und aliphatische C-H-Streckschwingungen um . Weiterhin sehen wir noch Gerüstschwingungen im Fingerprint-Bereich. Es handelt sich hierbei im Übrigen um ein IR-Spektrum von Mesitylen.
Bei einem so bedeutenden Algorithmus wie der diskrete Fourier-Transformation
bietet NumPy selbstverständlich eine eigene Implementierung. Diese können wir
mit der Funktion
np.fft.fft
wie folgt aufrufen:
int_nu_np = np.fft.fft(int_x)
nu_np = np.fft.fftfreq(nx, d=dx)
Das Umsortieren der Frequenzen und des Spektrums erfolgt automatisch mit der Funktion
np.fft.fftshift:
int_nu_np = np.fft.fftshift(int_nu_np)
nu_np = np.fft.fftshift(nu_np)
Es wird Ihnen vielleicht auffallen, dass die NumPy-Implementierung deutlich schneller ist als unsere eigene. Das liegt einerseits daran, dass NumPy in C/C++ geschrieben ist und deshalb grundsätzlich schneller arbeitet als Python. Andererseits liegt es daran, dass in NumPy eine effiziente Implementierung der diskreten Fourier-Transformation, die sog. Fast Fourier Transformation (FFT), verwendet wird. Unsere Implementierung der DFT hat eine Komplexität von , d.h., dass die die Laufzeit quadratisch mit der Anzahl der Punkte steigt. Die FFT hat dagegen eine Komplexität von lediglich .
Dabei sei gesagt, dass die FFT keine Nährung der DFT ist, sondern eine exakte Implementierung. Wir können die Skalierungen der Intensitäten und Frequenzen aus der FFT analog anwenden und die Ergebnisse mit denen unserer eigener Implementierung vergleichen:
int_nu_np *= np.exp(-1j * 2 * np.pi * nu_np * x_grid[0])
nu_np /= 100 # cm^-1
assert np.allclose(int_nu, int_nu_np)
assert np.allclose(nu, nu_np)
Übung
Aufgabe 1: IR-Spektrum von Mesitylen aus Molekulardynamik-Simulation
Das IR-Spektrum eines Moleküls ist eng mit der Änderung des Dipolmoments des Moleküls verbunden. Mit Hilfe quantenmechanischer Überlegungen kann gezeigt werden, dass das IR-Spektrum proportional zur Fourier-Transformation der Autokorrelationsfunktion des Dipolmoments ist:
Die Autokorrelationsfunktion des Dipolmoments ist gegeben durch
und beschreibt die zeitliche Korrelation (d.h. die Ähnlichkeit) des Dipolmoments zum Zeitpunkt mit dem Dipolmoment zu einem späteren Zeitpunkt . Das Wiener-Chintschin-Theorem besagt, dass die Autokorrelationsfunktion ebenso aus der Fourier-Transformation des Dipolmoments berechnet werden kann:
Setzen wir diese Beziehung in die erste Gleichung ein, so erhalten wir für das IR-Spektrum
da sich Fourier-Transformation und Inverse Fourier-Transformation gegenseitig aufheben.
(a) Berechnen des IR-Spektrums aus dem Dipolmoment
Berechnen Sie anhand der oben gegebenen Gleichung das IR-Spektrum von Mesitylen aus dem Dipolmoment , welches aus einer Molekulardynamik-Simulation (MD) erhalten wurde und hier heruntergeladen werden kann. Vergleichen Sie das erhaltene Spektrum mit dem experimentell gemessenen Spektrum von Mesitylen, welches Sie hierfinden können.
Verwenden Sie erneut die Funktionen aus der numpy.fft Bibliothek, um die (diskrete) Fourier-Transformation
durchzuführen. Beachten Sie, dass drei Komponenten hat, die jeweils separat transformiert
werden müssen. Berechnen Sie anschließend die quadrierte Norm des transformierten Dipolmoments
entlang dieser drei Komponenten, um eine reelle Größe zu erhalten.
(b) Fourier-Unschärfeprinzip
Wie verändert sich das erhaltene Spektum, wenn Sie nur die ersten 3 ps der MD Simulation verwenden? Und was beobachten Sie, wenn Sie nur jeden zweiten Wert der Dipolmomente verwenden?
Aufgabe 2: Eigenschaften der Fourier-Transformation
Zeigen Sie, dass Gleichungen (3.10) und (3.11) für die Fourier-Transformation gelten. Verwenden Sie dabei die Definition der Fourier-Transformation, sowie die Integration durch Substitution.
Zusatzaufgabe: Matrix- und Vektormultiplikation
In den folgenden Kapiteln werden wir häufig mit Matrizen und Vektoren verschiedener Dimensionen arbeiten. Daher ist es wichtig, die Regeln für die Multiplikation von Matrizen und Vektoren zu kennen.
Betrachten Sie die folgenden Matrizen und Vektoren, wobei :
Überprüfen Sie, ob die folgenden Multiplikationen möglich sind. Wenn ja, berechnen Sie das Ergebnis:
(i)
(ii)
(iii)
(iv)
(v)
(vi)
(vii)
(viii) , bzw.
Überprüfen Sie Ihre Ergebnisse in Pyhton. Nützliche Funktionen und Operationen sind u. a.
@, np.matmul, np.dot, *, np.transpose, np.linalg.norm und np.outer.
Eigenwert- und Singulärwertzerlegung
Eigenwerte und Eigenvektoren einer Quadratischen Matrix sollten Ihnen bekannt sein. Hat man alle Eigenwerte und Eigenvektoren einer diagonalisierbaren Matrix gefunden, kann diese als dargestellt werden, wobei eine Diagonalmatrix der Eigenwerte und die Matrix der Eigenvektoren ist. Diese Darstellung wird als Eigenwertzerlegung bezeichnet.
Die Singulärwertzerlegung kann als eine Verallgemeinerung der Eigenwertzerlegung betrachtet werden, da sie für Matrizen beliebiger Dimension definiert ist. Die Singulärwertzerlegung einer Matrix ist gegeben durch , wobei eine Diagonalmatrix der Singulärwerte und und orthogonale Matrizen sind. Die genauen Hintergründe werden wir noch in diesem Kapitel kennenlernen.
Nach der Meinung der Autoren ist die Singulärwertzerlegung die wichtigste Matrixzerlegung in der (numerischen) linearen Algebra und umfasst unzählige Anwendungen in der Naturwissenschaft, Technik und darüber hinaus. Gleichzeitig bildet sie die Basis für viele moderne Algorithmen. In diesem Kapitel werden wir uns mit der Eigenwert- und Singulärwertzerlegung beschäftigen und ihre Bedeutung für die Quantenchemie, die Datenanalyse und das maschinelle Lernen diskutieren.
Eigenwertzerlegung
Für die Definition der Eigenwertzerlegung (engl. eigenvalue decomposition, EVD) benötigen wir zunächst die Definition eines Vektorraums. Obwohl dieser viel allgemeiner definiert wird, beschränken wir uns hier auf , also den -dimensionalen Vektorraum über die komplexen Zahlen. Die Vektoren in können in der Standardbasis als Spaltenvektoren dargestellt werden. Die Notation bezeichnet einen solchen Vektor: mit den Komponenten . Ein komplex konjugierter Zeilenvektor wird als der hermitesch Konjugierte eines Spaltenvektors notiert: mit den komplex konjugierten Komponenten . Das Standard-Skalarprodukt zweier Vektoren kann in dieser Notation als geschrieben werden, wobei die Regel für Matrixmultiplikation gilt.
Sei nun eine quadratische Matrix. Ein Vektor heißt Eigenvektor von , wenn für ein gilt. Die Zahl wird als Eigenwert von zum Eigenvektor bezeichnet.
Theoretische Grundlagen
Definieren wir nun die Diagonalmatrix der Eigenwerte als und die Matrix der Eigenvektoren als dann kann die Eigenwertzerlegung von als geschrieben werden.
Eigenwertzerlegung für normale Matrizen
Obwohl die Eigenwertzerlegung für alle diagonalisierbaren Matrizen existiert, wollen wir uns hier auf eine Untermenge solcher Matrizen beschränken, die sogenannten Normalen Matrizen. Eine Matrix heißt normal, wenn sie mit ihrer Transponierten kommutiert, also wenn gilt. Solche Matrizen sind unitärdagonalisierbar, d.h. die Matrix ist unitär. Damit gilt
Für eine normale Matrix kann die Eigenwertzerlegung also als
geschrieben werden. Das Produkt eines Spaltenvektors mit einem Zeilenvektor, wie z.B. in der obigen Gleichung, ist dabei als das dyadische Produkt bekannt und produziert eine (Rang-1-)Matrix.
Um die Eigenwertzerlegung durchführen zu können, müssen wir demnach zunächst die Eigenwerte und Eigenvektoren der Matrix bestimmen. Während sie für kleine Matrizen noch analytisch bestimmt werden können, sind numerische Verfahren für größere Matrizen notwendig. Wir werden uns im Folgenden mit zwei solcher Verfahren beschäftigen.
Eigenwertalgorithmen
Der wohl einfachste Algorithmus zur Bestimmung der Eigenwerte einer Matrix ist die Potenzmethode. Dieser Algorithmus findet den Eigenvektor der Matrix zum Eigenwert mit dem größten Betrag, sofern diagonalisierbar ist.
Wir nehmen dazu an, dass eine Matrix mit (unbekannten) Eigenwerten ist. Dann liefert die Iteration für einen beliebigen Startvektor eine Folge von Vektoren , die gegen den Eigenvektor zum Eigenwert mit dem größten Betrag, , konvergiert.
Den zugehörigen Eigenwert kann dann durch bestimmt werden.
Für matheamtisch Interessierte ist hier ein Beweis der Konvergenz der Potenzmethode zu finden.
Beweis der Konvergenz der Potenzmethode
Eine Matrix ist diagonalisierbar, genau denn wenn ihre Eigenvektoren eine Basis bilden. Deshalb kann man den beliebigen Startvektor als Linearkombination der Eigenvektoren schreiben: mit Entwicklungskoeffizienten und Eigenvektoren zu den Eigenwerten . Dann gilt Da für , geht der zweite Term in der Klammer für gegen Null, sodass Mit einer endlichen Anzahl an Iterationen kann also als eine Approximation des Eigenvektors angesehen werden, zumal Eigenvektoren beliebig skaliert werden können.
Für die numerische Stabilität ist es sinnvoll, den Vektor in jedem Schritt zu normieren, was zu der Iteration führt.
Eine Erweiterung der Potenzmethode ist die sog. inverse Iteration, die zur Bestimmung eines Eigenwertes nahe einer vorgegeben aber beliebigen Zahl sowie dessen Eigenvektor verwendet werden kann.
In den meisten Fällen sind wir allerdings an allen Eigenvektoren und Eigenwerten einer Matrix interessiert. Ein häufig verwendetes Verfahren, welches alle Eigenpaare einer diagonalisierbaren Matrix bestimmen kann, ist der QR-Algorithmus.
Der Schlüsselschritt des QR-Algorithmus ist die Zerlegung der Matrix in eine unitäre Matrix und eine obere Dreiecksmatrix , also . Diese Zerlegung wird als QR-Zerlegung bezeichnet. Wir werden hier nicht auf die Details der QR-Zerlegung eingehen, sondern annehmen, dass wir sie für jede quadratische Matrix berechnen können.
Beim QR-Algorithmus wird die Ausgangsmatrix als gesetzt und ihre QR-Zerlegung berechnet. Dann wird die Iteration durchgeführt; die Matrix wird im nächsten Schritt also durch die Multiplikation der Faktoren der QR-Zerlegung in umgekehrter Reihenfolge berechnet. Es gilt wobei die exakte aber unbekannte Eigenwertzerlegung der Ausgangsmatrix ist. Insgesamt gilt also, dass die Matrix die gleichen Eigenwerte wie die Ausgangsmatrix hat.
Es kann gezeigt werden, dass die Folge der Matrizen gegen eine obere Dreiecksmatrix konvergiert, deren Eigenwerte auf der Diagonalen stehen. Diese Eigenwerte sind dann gleichzeitig die Eigenwerte der Ausgangsmatrix . Ist die Ausgangsmatrix zudem normal, so konvergiert die Folge der Matrizen gegen eine Diagonalmatrix, da alle normalen Dreiecksmatrizen diagonal sind. Da die Matrix der Eigenvektoren eine Diagonalmatrix die Einheitsmatrix ist, also , folgt aus der obigen Definition dass womit wir die Eigenvektoren der normalen Matrix approximieren können.
Der QR-Algorithmus lässt sich auf verschiedene Weisen beschleunigen, die wir hier nicht weiter diskutieren wollen.
Implementierung
Nun wollen wir die Eigenwertzerlegung mit der Potenzmethode und dem QR-Algorithmus implementieren. Zum Testen wollen wir zunächst zufällige reelle Matrizen wählen. Damit diese Matrix auf jeden Fall diagonalisierbar ist, werden wir sie mit ihrer Transponierten addieren, um eine symmetrische Matrix zu erhalten.
Potenzmethode
Nach dem Importieren von NumPy
import numpy as np
implementieren wir die Potenzmethode nach Gl. (4.2) als eine Funktion:
def power_iteration(
a: np.ndarray, eps: float, maxiter: int = 1000,
) -> (float, np.ndarray):
n = a.shape[0]
x = np.random.rand(n)
x /= np.linalg.norm(x)
x_prev = np.zeros(n)
for i in range(0, maxiter):
x_prev = x
x = np.dot(a, x)
x /= np.linalg.norm(x)
if np.linalg.norm(x - x_prev) < eps:
break
if i == maxiter - 1:
print('Power iteration did not converge.')
print('Residual norm:', np.linalg.norm(x - x_prev))
lambda_ = np.dot(np.dot(x, a), x)
return lambda_, x
Diese Funktion akzeptiert die Argumente a (Matrix ),
eps (Fehlergrenze für die Konvergenz) und maxiter (maximale Anzahl an
Iterationen). Sie gibt den betragsmäßig größten Eigenwert und den zugehörigen
Eigenvektor zurück.
Als erstes wird die Dimension der Matrix mit a.shape[0] bestimmt und in der
Variable n gespeichert. Dann haben wir die Funktion
np.random.rand
verwendet, um einen zufälligen Startvektor zu generieren, welcher
anschließend durch deine Norm geteilt und damit normiert wird. Dazu wurde die
Funktion
np.linalg.norm
verwendet, um die (euklidische) Norm eines Vektors zu berechnen.
Eine mögliche Abbruchbedingung stellt dar, wenn die Norm der Differenz zwischen den
Vektoren in zwei aufeinanderfolgenden Iterationen kleiner als eine gegebene
Fehlergrenze eps ist. Um dies zu implmentieren, haben wir die Variable x_prev
definiert, die den Vektor in der vorherigen Iteration speichert.
Danach beginnt die Iteration mit einer for-Schleife, die bis zur maximalen
Anzahl an Iterationen maxiter läuft. In jeder Iteration wird zuerst der
wert von x in x_prev gespeichert. Dann wird der neue Vektor x durch
np.dot(a, x) berechnet und anschließend normiert. Danach wird überprüft,
ob die Norm der Differenz zwischen x und x_prev kleiner als eps ist.
Ist dies der Fall, wird die Schleife abgebrochen.
Nimmt die Laufvariable i den Wert maxiter - 1 an, so ist die Potenzmethode
möglicherweise nicht konvergiert und es wird eine Warnung ausgeschrieben.
Anschließend wird der Eigenwert mit Gl. (4.3)
berechnet. Am Ende werden die Ergebnisse zurückgegeben.
Manchmal kommt es vor, dass der gewünschte Variablenname bereits ein
reservierter Befehl in Python ist. In diesem Fall kann entweder ein anderer
Name gewählt werden oder, gemäß der Konvention, ein Unterstrich an den Namen
angehängt werden. Hier wurde z.B. die Variable lambda_ verwendet, um den
Eigenwert zu speichern.
Im Folgenden erzeugen wir eine symmetrisierte Zufallsmatrix a_mat und rufen
die Funktion power_iteration mit eps=1e-8 auf:
NDIM = 10
a_mat = np.random.rand(NDIM, NDIM)
a_mat = a_mat + a_mat.T
lambda_, x = power_iteration(a_mat, 1e-8)
Um unsere Ergebnisse zu verifizieren, verwenden wir die Funktion
np.linalg.eigh,
welche die Eigenwerte und Eigenvektoren einer symmetrischen Matrix berechnet.
Dabei werden die Eigenwerte in aufsteigender Reihenfolge in dem 1D-Array eigvals und die
normierten Eigenvektoren als Spalten in dem 2D-Array eigvecs zurückgegeben.
Aus diesem Grund nehmen wir den letzten Eintrag der Eigenwerte und den zugehörigen
Eigenvektor, um sie mit den Ergebnissen der Potenzmethode zu vergleichen:
eigvals, eigvecs = np.linalg.eigh(a_mat)
lambda_ref = eigvals[-1]
x_ref = eigvecs[:, -1]
print(lambda_, lambda_ref)
print(x, x_ref)
assert np.isclose(lambda_, lambda_ref)
assert np.allclose(np.abs(np.dot(x, x_ref)), 1.0)
Während wir die Eigenwerte direkt mit np.isclose vergleichen können,
müssen wir beim Eigenvektor berücksichtigen, dass dieser nur
bis auf eine Skalierung eindeutig sind. Da unsere Eigenvektoren normiert
sind, können wir die Projektion der Eigenvektoren aufeinander
mit np.dot berechnen und der Absolutwert der Projektion sollte
nahe bei 1 sein.
Ein wiederholtes Ausführen unseres Codes würde unsere Implementierung der Potenzmethode an verschiedenen Beispielen testen.
QR-Algorithmus
Wir implementieren nun den QR-Algorithmus nach
Gl. (4.4) und Gl. (4.5).
Nach dem Importieren von NumPy definieren wir die Funktion qr_algorithm,
welche die gleiche Signatur wie die Funktion power_iteration hat:
def qr_algorithm(
a: np.ndarray, eps: float, maxiter: int = 5000,
) -> (np.ndarray, np.ndarray):
n = a.shape[0]
a_k = a.copy()
eigvecs = np.eye(n)
for i in range(0, maxiter):
q_k, r_k = np.linalg.qr(a_k)
a_k = np.dot(r_k, q_k)
eigvecs = eigvecs @ q_k
if np.max(np.abs(np.tril(a_k, k=-1))) < eps:
break
if i == maxiter - 1:
print('QR algorithm did not converge.')
print('Max residual:', np.max(np.abs(np.tril(a_k, k=-1))))
eigvals = np.diag(a_k)
sort_idx = np.argsort(eigvals)
eigvals = eigvals[sort_idx]
eigvecs = eigvecs[:, sort_idx]
return eigvals, eigvecs
Nach der Bestimmung der Dimension wird die Ausgangsmatrix a in a_k
kopiert. Es ist hier wichtig, dass eine explizite Kopie von a gemacht wird,
da der QR-Algorithmus die Matrix a ansonsten verändern würde. Um die
zukünftigen sequentiell multiplizieren zu können, wird die
Variable eigvecs als eine Einheitsmatrix initialisiert.
Innerhalb der for-Schleife, die bis maxiter läuft, wird die zuerst die QR-Zerlegung von
a_k berechnet. Dazu haben wir die Funktion
np.linalg.qr
verwendet. Danach überschreiben wir a_k mit dem Produkt von r_k und q_k
und multiplizieren eigvecs mit q_k.
Da a_k gegen eine obere Dreiecksmatrix (bzw. in diesem Fall Diagonalmatrix)
anstrebt, können wir als Abbruchbedingung den größten Betrag der Elemente
im strikten unteren Dreieck der Matrix verwenden. Hierzu wird die Funktion
np.tril
mit dem Argument k=-1 verwendet, um das untere Dreieck der Matrix
zu erhalten. Ist dieser Betrag kleiner als die Fehlergrenze eps, wird die
Schleife abgebrochen. Anschließend wird, wie bei der Implementierung der
Potenzmethode, überprüft, ob der Algorithmus konvergiert ist und ggf. eine
Warnung ausgegeben.
Die Eigenwerte werden aus der Diagonale der Matrix a_k mit np.diag
extrahiert. Am Ende wird die Reihenfolge der Indizes der Eigenwerte mit
np.argsort berechnet und die Eigenwerte, sowie die Eigenvektoren entsprechend
sortiert, bevor sie zurückgegeben werden.
Auch hier testen wir die Implementierung mit einer symmetrischen Zufallsmatrix
NDIM = 10
a_mat = np.random.rand(NDIM, NDIM)
a_mat = a_mat + a_mat.T
eigvals, eigvecs = qr_algorithm(a_mat, 1e-8)
und vergleichen unsere Ergebnisse mit denen von np.linalg.eigh:
eigvals_ref, eigvecs_ref = np.linalg.eigh(a_mat)
print(eigvals)
print(eigvals_ref)
assert np.allclose(eigvals, eigvals_ref)
assert np.allclose(np.abs(eigvecs.T @ eigvecs_ref), np.eye(NDIM))
Die paarweise Projektion unserer Eigenvektoren auf alle Eigenvektoren von
np.linalg.eigh kann in diesem Fall elegant mit der Matrixmultiplikation
eigvecs.T @ eigvecs_ref durchgeführt werden. Im Falle eines erfolgreich
konvergierten QR-Algorithmus
sollte dieses Produkt einer Diagonalmatrix entsprechen, deren Diagonalelemente nahe
bei 1 oder -1 liegen. Deshalb nehmen wir den Absolutbetrag der Elemente aus
der Produktmatrix und vergleichen sie mit der Einheitsmatrix, die wir mit
np.eye
erzeugen.
Die Eigenwertzerlegung liefert uns viele essentielle Informationen über die korrespondierende quadratische Matrix. Können wir etwas ähnliches auch für nicht-quadratische Matrizen finden? Wie Sie im nächsten Abschnitt sehen werden, ist die Antwort ist ein klares Ja. Wir werden mit der Singulärwertzerlegung eine Methode kennenlernen, die als die Verallgemeinerung der Eigenwertzerlegung für allgemeine Matrizen betrachtet werden kann.
Übung
Aufgabe 1: Hückel-Theorie
Sie kennen bereits das LCAO-Verfahren (Linear Combination of Atomic Orbitals), bei dem Molekülorbitale als Linearkombination von Atomorbitalen dargestellt werden. Die Hückel-Theorie ist eine vereinfachte Methode zur Berechnung der Molekülorbitale von konjugierten Molekülen. Sie basiert auf der Beobachtung, dass die elektronische Struktur konjugierter Moleküle hauptsächlich durch die π-Elektronen bestimmt wird. Die Hückel-Molekülorbitale werden daher als Linearkombination der p-Orbitale der (schweren) Atome dargestellt:
Um die Koeffizienten zu bestimmen und die Molekülorbitale berechnen zu können, müssen wir die Schrödingergleichung für das Molekül aufstellen und lösen. Dabei machen wir die folgenden Annahmen:
- Die p-Orbitale der Atome sind orthogonal zueinander, d.h. .
- Die Wechselwirkung zwischen den p-Orbitalen wird nur zwischen den nächsten Nachbarn berücksichtigt, d.h. für und für .
- Die Diagonalelemente der Hamilton-Matrix sind .
(a) Herleiten des Eigenwertproblems
Zeigen Sie, dass unter diesen Annahmen die Schrödingergleichung zu einem Eigenwertproblem der Form
wird, wobei die Hamilton-Matrix (bzw. Hückel-Matrix), die Matrix der Koeffizienten und die Diagonalmatrix der Molekülorbital-Energien ist.
(b) Berechnung der Molekülorbitale von Hexatrien
Stellen Sie die Hückel-Matrix für das Hexatrien-Molekül auf und berechnen Sie die Molekülorbital-Energien und Koeffizienten im Rahmen der Hückel-Theorie. Verwenden Sie dazu die Parameter und . Versuchen Sie auch, die Molekülorbitale zu visualisieren, z.B. als eine Kette von Punkten mit Radien proportional zu den Koeffizienten. Geben Sie auch die Phase der Orbitale (Vorzeichen der Koeffizienten) als verschiedene Farben an.
(c) Berechnung der Molekülorbitale von Benzol
Was müssen Sie bei Ihrer Lösung in (b) ändern, um die Hückel-Matrix von Benzol aufzustellen? Berechnen Sie die Molekülorbital-Energien von Benzol und bestimmen Sie die Gesamtenergie des Moleküls gemäß
wobei die Anzahl der doppelt besetzten Molekülorbitale ist. Vergleichen Sie die Energie von Benzol mit der Energie von drei isolierten Doppelbindungen und erklären Sie den Unterschied.
Singulärwertzerlegung
Definition
Im Sinne der Eigenwertzerlegung für quadratische Matrizen definieren wir die folgende Zerlegung für eine beliebige Matrix : wobei und unitäre Matrizen sind und eine Diagonalmatrix mit nicht-negativen Diagonaleinträgen ist, wobei . Diese Zerlegung wird als Singulärwertzerlegung (engl. singular value decomposition, SVD) bezeichnet.
Aber was bedeutet es, dass eine rechteckige Matrix diagonal ist? Streng genommen ist das nicht möglich, aber wir können die Matrix in eine Form bringen, die der Diagonalform ähnelt, nämlich Demnach enthält eine Diagonalmatrix und möglicherweise Nullmatrizen, um die Dimensionen zu erfüllen.
Die Diagonaleinträge werden als Singulärwerte bezeichnet und die Spalten von und heißen Links- bzw. Rechts-Singulärvektoren. In Analogie zu Gl. (4.1) lässt sich die Singulärwertzerlegung als schreiben.
Man erkennt hier einen deutlichen Unterschied zur Eigenwertzerlegung: Während dort alle Eigenvektoren wichtig sind, tragen hier bzw. Singulärvektoren nicht zur Zerlegung bei. Das bedeutet, dass eine der Matrizen oder reduziert werden kann, ohne die Zerlegung zu verändern. Nehmen wir ohne Einschränkung der Allgemeinheit an, so können wir und durch Entfernen der letzten Spalten von und der letzten Zeilen von definieren. Zudem definieren wir . Dann gilt die Zerlegung welche als econiomic singular value decomposition bekannt ist. Sollte eine der Dimensionen viel größer sein als die andere, so ist die economic SVD von Vorteil, weil sie weniger Speicherplatz benötigt.
Eigenschaften
Wir Betrachten nun die Matrixprodukte und unter Verwendung der SVD. Es gilt Weil die von Null verschiedenen Elemente in nur auf der Diagonalen stehen, sind die Produkte und Diagonalmatrizen mit den Diagonalelementen .
Die zwei Gleichungen (4.7) ähneln der Eigenwertzerlegung für normale Matrizen sehr. Tatsächlich sind die Produkte und sogar hermitesch, weil wodurch sie außerdem normal sind. Aus diesem Grund entsprechen die Singulärwerte den Wurzeln der Eigenwerte von und . Die Links-Singulärvektoren sind demnach die Eigenvektoren von , während die Rechts-Singulärvektoren die Eigenvektoren von darstellen. Somit haben wir eine Verbindung zwischen der SVD und der EVD hergestellt.
Obwohl Gl. (4.7) mathematisch korrekt ist, sollte man die SVD von nicht über die EVD von und berechnen:
Die Eigenvektoren einer Matrix sind nur bis auf einen (komplexen) Faktor eindeutig. Selbst wenn man die Eigenvektoren normiert, bleibt die Phase unbestimmt. Versieht man z.B. den ersten Links-Singulärvektor mit einem Minuszeichen, bleiben die Gleichungen (4.7) gültig. Die SVD in Gl. (4.6) ist dann jedoch nicht mehr korrekt, da der ersten Term ein Minuszeichen erhält. Damit die Zerlegung weiterhin stimmt, müsste das Vorzeichen von angepasst werden. Da die zwei EVDs aber unabhängig von einander sind, sind solche Anpassungen nicht möglich.
Aus diesem Grund sollte man die SVD nicht aus der doppleten EVD berechnen, es sei denn, es werden lediglich die Singulärwerte und nicht die Singulärvektoren benötigt. Für die folgenden Überlegungen ist es ausreichend zu wissen, dass es gibt spezielle Algorithmen gibt, die die Links- und Rechts-Singulärvektoren simultan berechnen.
Verwendung mit NumPy
Die Singulärwertzerlegung ist in NumPy über die Funktion
numpy.linalg.svd
verfügbar. Als Argument übergibt man die Matrix , von der die SVD
berechnet werden soll. Als Rückgabewerte erhält man die Matrizen ,
und . Mit dem optionalen Argument
full_matrices=False wird die economic SVD berechnet. Die Standardoption ist
full_matrices=True. Ein Beispiel-Aufruf sieht wie folgt aus:
import numpy as np
a = np.random.rand(9, 6)
u, s, vh = np.linalg.svd(a, full_matrices=False)
print(u.shape) # (9, 6)
print(s.shape) # (6,)
print(vh.shape) # (6, 6)
Übung
Aufgabe 2: SVD und Eigengesichter
Die Singulärwertzerlegung (SVD) ist ein wichtiges Werkzeug in der Analyse von großen Datensätzen. Ein bekanntes Anwendungsbeispiel ist die Gesichtserkennung, bei der die SVD dazu verwendet wird, die sogenannten Eigengesichter zu berechnen. Diese repräsentieren die Hauptkomponenten der Gesichter in einem Datensatz und können dazu verwendet werden, Gesichter zu klassifizieren oder zu rekonstruieren.
Wir werden in dieser Aufgabe mit dem Yale B Datensatz arbeiten, der von 38 Personen jeweils ca. 64 Bildern in
verschiedenen Lichtverhältnissen enthält. Die Bilder sind in schwarz-weiß und haben eine Auflösung von 168 x 192
Pixeln. Sie können den Datensatz hier
herunterladen und mit Hilfe des folgenden Codes in einem Array faces speichern, wobei jede Spalte in faces
ein Bild darstellt:
import numpy as np
import matplotlib.pyplot as plt
import scipy as sp
# Import Yale Faces dataset
path = 'allFaces.mat'
data = sp.io.loadmat(path)
# Extract the data
faces = data['faces']
n = int(data['n'][0][0])
m = int(data['m'][0][0])
nfaces = data['nfaces'].flatten() # number of faces per person
# Plot first face
plt.imshow(faces[:,0].reshape(m,n).T, cmap='gray')
(a) Plotten der Bilder
Erkunden Sie den Datensatz, indem Sie fünf zufällige Bilder aus dem Datensatz nebeneinander plotten.
(b) Berechnung der Eigengesichter
Wir wollen zunächst die Hauptkomponenten der Gesichter im Datensatz bestimmen. Führen Sie dazu eine (economy)
Singulärwertzerlegung (4.6) der Matrix faces durch, wobei Sie nur die
ersten 36 Personen, d.h. 2282 Bilder verwenden. Beachten Sie außerdem, dass Sie vorher den Mittelwert dieser
Bilder von dem Array faces subtrahieren müssen. Die Eigengesichter entsprechen dann den Spalten der Matrix
. Plotten Sie auch die ersten fünf Eigengesichter.
(c) Rekonstruktion der Bilder
Da die (complete) SVD eine exakte Zerlegung der Datenmatrix ist, sollten wir in der Lage sein, die Bilder aus dem Datensatz mit Hilfe der Eigengesichter zu rekonstruieren. Die Eigengesichter dienen dabei als Basisvektoren, aus denen die Bilder als Linearkombinationen zusammengesetzt werden.
Nutzen wir hingegen nur die ersten Eigengesichter, so erhalten wir eine Approximation der Bilder. Die Koeffizienten der Linearkombination erhalten wir durch Projektion der Bilder auf die ersten Eigengesichter, d.h. Spalten der Matrix :
wobei ein Bildvektor ist. Das rekonstruierte Bild ergibt sich dann durch
Damit haben wir eine Reduktion der Dimensionalität erreicht, da wir anstatt der ursprünglichen 168 x 192 Pixel nur noch Koeffizienten speichern müssen.
Wählen Sie nun aus dem Datensatz ein Bild der zwei Personen, welche wir nicht für die SVD verwendet haben, z.B. das Bild mit Index 2282, und rekonstruieren Sie es mit Hilfe der ersten Eigengesichter. Plotten Sie die rekonstruierten Bilder für die verschiedenen . Was beobachten Sie?
Für die Effizienz der Rekonstruktion ist es sinnvoll, zuerst den Vektor zu berechnen und diesen dann mit den Eigengesichtern zu multiplizieren. Vergessen Sie nicht, von den Mittelwert abzuziehen, bevor Sie die Rekonstruktion durchführen und diesen anschließend zu wieder hinzuzufügen.
(d) Rekonstruktion von anderen Motiven
Wir können die Basis der Eigengesichter auch verwenden, um Bilder mit anderen Motiven darzustellen. Laden Sie dazu dieses Bild eines Hundes herunter, importieren Sie es mit
plt.imread('dog.jpg', format='jpeg')[:,:,0].T.flatten()
und führen Sie die Rekonstruktion wie in (c) durch. Was beobachten Sie?
Hauptkomponentenanalyse
Wir betrachten wieder die Singulärwertzerlegung einer Matrix in der Form mit Singulärvektoren und , sowie Singulärwerten (vgl. Gl. (4.6)). Diese Gleichung suggestiert eine Approximation der Matrix durch Abschneiden der Summe nach dem -ten Summanden: wobei wir absteigend sortierte Singulärwerten annehmen, und . Man spricht hier von einer Rang--Approximation der Matrix , da aus der Summe von (linear unabhängigen) Rang-1-Matrizen besteht. Wie gut ist diese Approximation? Eine Antwort auf diese Frage liefert das folgende Theorem, welches erstmals von Erhard Schmidt bewiesen wurde:
Sei eine beliebige Matrix mit der SVD , wobei und . Dann ist die Matrix die beste Rang--Approximation von im Sinne der Frobenius-Norm , also
Die Frobenius-Norm einer Matrix ist definiert als
Der Beweis dieses Theorems erfordert einige Kenntnisse der linearen Algebra, weswegen wir hier auf den Beweis verzichten. Interessierte können ihn z.B. hier nachlesen.
Eckart-Young-Mirsky-Theorem
Leon Mirsky konnte die obige Approximationseigenschaft auf beliebig unitär invariante Normen erweitern. Eine Norm ist unitär invariant, wenn für beliebige unitäre Matrizen und die Bedingung für alle Matrizen erfüllt ist.
Einige gebräuchliche unitär invariante Normen seien hier für mit aufgeführt:
- Die Frobenius-Norm
- die Spektralnorm
- und die Ky-Fan-Norm oder auch Spurnorm
Alle drei Normen sind Spezialfälle der Schatten-Normen.
Das Eckart-Young-Theorem besagt also, dass die SVD nicht nur eine gute Approximation der Matrix liefert, sondern sogar die beste Approximation bis zum Rang bezüglich der Frobenius-Norm ist.
Die SVD liefert uns aber noch mehr als nur eine Approximation der Matrix . Da die Approximation durch die gewichtete Summe von Rang-1-Matrizen erfolgt, kann diejenige Rang-1-Matrix korrespondierend zum Größten Singulärwert als die wichtigste Komponente, die Hauptkomponente, der Matrix interpretiert werden. Der Singulärwert gibt dabei an, wie wichtig diese Hauptkomponente für die Matrix ist. Die weiteren Rang-1-Matrizen mit können dann als die nachfolgenden Komponenten interpretiert werden, mit dem jeweiligen Gewicht . Diese Interpretation führt uns zur Hauptkomponentenanalyse (engl. Principal Component Analysis, PCA), welche in der Praxis häufig zur Dimensionsreduktion von Daten verwendet wird.
Theoretische Grundlagen
Wir betrachten eine Messung von jeweils Merkmalen (engl. features) an Proben (engl. samples), welche durch eine -Matrix repräsentiert wird. Die -te Zeile der Matrix entspricht dabei den Merkmalen der -ten Probe.
Stellen Sie sich als Beispiel eine Messreihe von Proben vor (z.B. bei verschiedenen Konzentrationen), wobei Sie für jede Probe Merkmale messen (Temperatur, Absorption, etc.). Die Datenmatrix repräsentiert dann Ihre gesamten Messdaten, wobei jede Zeile die Merkmale einer Messung enthält.
Wir werden in den folgenden Abschnitten und Kapiteln häufig von dieser Darstellung der Daten ausgehen, da sie die Basis für die meisten Methoden der Datenanalyse und des maschinellen Lernens bildet.
Der erste Schritt der PCA ist die Vorverarbeitung der Daten auf eine der folgenden zwei Arten: Zentrierung oder Standardisierung. Hierzu schreiben wir die Datenmatrix als mit den Messdaten . Jeder Datenpunkt ist also ein -dimensionaler Vektor, wobei die Features die Basisvektoren darstellen. Nun definieren wir den Mittelwert über alle Messungen als Bei der Zentrierung subtrahieren wir von jedem Datenpunkt diesen Mittelwert um die zentrierte Datenmatrix zu erhalten. Man kann sich leicht davon überzeugen, dass die zentrierte Datenmatrix durch gegeben ist, wobei die -Einheitsmatrix und eine -Matrix mit lauter Einsen ist.
Manchmal kommt es vor, dass die Features in unterschiedlichen Größenordnungen auftreten, bzw. in unterschiedlichen Einheiten gemessen wurden. In diesem Fall ist es sinnvoll, zusätzlich zu der Mittelwertsubtraktion auch eine Normierung der Daten durchzuführen. Die Kombination dieser zwei Schritte wird als Standardisierung bezeichnet. Nach der Berechnung der Standardabweichung der -ten Features durch kann die standardisierte Datenmatrix durch berechnet werden. In anderen Worten heißt das, dass die Gesamtheit unserer Messdaten nun Mittelwert 0 und Standardabweichung 1 haben. Je nach Art der Daten muss entschieden werden, ob eine Zentrierung oder Standardisierung durchgeführt werden sollte.
Anschließend berechnen wir die Singulärwertzerlegung der standardisierten Datenmatrix : mit , und . Die Rechts-Singulärvektoren sind die Hauptkomponenten, die auch als loadings bezeichnet werden, während die Singulärwerte die Gewichtung der jeweiligen Hauptkomponente angeben. Gebräuchlich ist noch der Varianzanteil (engl. explained variance) der -ten Hauptkomponente, die durch gegeben ist, wobei die Summe über alle Singulärwerte läuft.
Da die Rechts-Singulärvektoren orthonormal sind, können wir diese als eine Orthonormalbasis des -dimensionalen Raums interpretieren, die möglicherweise eine natürlichere Repräsentation der Datenpunkte darstellt.
Die Projektion der Datenpunkte auf die Hauptkomponenten kann durch das Matrixprodukt berechnet werden. Demnach ist die Projektion der Datenpunkte auf die -te Hauptkomponente durch das Produkt des -ten Links-Singulärvektors mit dem -ten Singulärwert gegeben. Diese Projektion wird auch als score der -ten Hauptkomponente bezeichnet.
Zwar ist die Transformation der Datenpunkte in die Basis der Hauptkomponenten bereits ein nützliches Werkzeug, die Hauptkomponentenanalyse kann aber auch zur Dimensionsreduktion verwendet werden. Die Idee ist dabei, dass wir nur die ersten Hauptkomponenten behalten und die Datenpunkte in den -dimensionalen Raum dieser Hauptkomponenten projizieren. Dank des Eckart-Young-Theorems wissen wir, dass diese Projektion immer die beste Beschreibung der ursprünglichen Datenpunkte in einem -dimensionalen Raum liefert. Also können wir die PCA verwenden, um einen hochdimensionalen Datensatz mit nur wenigen Hauptkomponenten zu approximieren, ohne dabei zu viel Informationen über die Daten zu verlieren. Wir werden diesen Aspekt im Kontext des maschinellen Lernens in einem späteren Kapitel erneut betrachten.
Implementierung
Wir implementieren die PCA am Beispiel des Weindatensatzes. Dieser enthält Messungen von 13 physikalischen und chemischen Eigenschaften von insgesamt 178 Weinen aus drei verschiedenen Rebsorten: Barolo, Grignolino und Barbera. Die ersten Einträge des Datensatzes haben die folgende Form:
1,14.23,1.71,2.43,15.6,127,2.8,3.06,.28,2.29,5.64,1.04,3.92,1065
1,13.2,1.78,2.14,11.2,100,2.65,2.76,.26,1.28,4.38,1.05,3.4,1050
1,13.16,2.36,2.67,18.6,101,2.8,3.24,.3,2.81,5.68,1.03,3.17,1185
1,14.37,1.95,2.5,16.8,113,3.85,3.49,.24,2.18,7.8,.86,3.45,1480
1,13.24,2.59,2.87,21,118,2.8,2.69,.39,1.82,4.32,1.04,2.93,735
1,14.2,1.76,2.45,15.2,112,3.27,3.39,.34,1.97,6.75,1.05,2.85,1450
1,14.39,1.87,2.45,14.6,96,2.5,2.52,.3,1.98,5.25,1.02,3.58,1290
1,14.06,2.15,2.61,17.6,121,2.6,2.51,.31,1.25,5.05,1.06,3.58,1295
1,14.83,1.64,2.17,14,97,2.8,2.98,.29,1.98,5.2,1.08,2.85,1045
1,13.86,1.35,2.27,16,98,2.98,3.15,.22,1.85,7.22,1.01,3.55,1045
und der gesamte Datensatz kann
hier heruntergeladen
werden. Die Datei wine.csv enthält die Daten im sogenannten
Comma-Separated Values (CSV) Format, also mit Werten, die durch Kommata
getrennt sind.
Als erstes importieren wir die benötigten Bibliotheken
import numpy as np
import matplotlib.pyplot as plt
und lesen die Daten aus der Datei wine.csv ein:
data = np.loadtxt('wine.csv', delimiter=',')
categories = data[:, 0].astype(int) - 1
features = data[:, 1:].astype(float)
Hier haben wir das Argument delimiter=',' an die Funktion np.loadtxt
übergeben, da die Werte in der Datei nicht wie bisher durch Leerzeichen
getrennt sind.
Zudem haben wir die Labels der Rebsorten (nullte Spalte) in der Variable
categories als 0-indizierte Integers gespeichert und somit von den
Eigenschaften der Weine, die wir als Floats in der Variable features
gespeichert haben, abgetrennt.
Weitere versteckte Informationen, wie die Namen der Rebsorten und der gemessenen Eigenschaften, sind für die Mathematik zwar nicht relevant, können aber für die Interpretation der Ergebnisse sehr hilfreich sein. Daher speichern wir diese in Listen:
CATEGORY_LABELS = ['Barolo', 'Grignolino', 'Barbera']
FEATURE_LABELS = [
'alcohol', 'malic acid', 'ash', 'alcalinity of ash', 'magnesium',
'total phenols', 'flavanoids', 'nonflavanoid phenols',
'proanthocyanins', 'color intensity', 'hue',
'OD280/OD315', 'proline',
]
Da sich in diesem Datensatz die Größenordnung der Eigenschaften sehr stark unterscheidet, führen wir eine Standardisierung der Daten durch:
features -= features.mean(axis=0)
features /= features.std(axis=0)
Anschließend berechnen wir die Singulärwertzerlegung der standardisierten Datenmatrix:
u, s, vh = np.linalg.svd(features, full_matrices=False)
pcs = vh.T
proj = u @ np.diag(s)
expl_var = s**2 / np.sum(s**2)
Zusätzlich haben wir die Hauptkomponenten, die Projektion der Datenpunkte auf die Hauptkomponenten, sowie die Varianzanteile bestimmt. Die Ergebnisse sehen wie folgt aus:
print('First principal component:')
print(pcs[:, 0])
print('Explained variance:')
print(expl_var)
assert np.allclose(
pcs[:, 0],
[-0.1443294, 0.24518758, 0.00205106, 0.23932041, -0.14199204, -0.39466085,
-0.4229343, 0.2985331, -0.31342949, 0.0886167, -0.29671456, -0.37616741,
-0.28675223],
)
assert np.allclose(
expl_var,
[0.36198848, 0.1920749, 0.11123631, 0.0706903, 0.06563294, 0.04935823,
0.04238679, 0.02680749, 0.02222153, 0.01930019, 0.01736836, 0.01298233,
0.00795215],
)
Wir sehen, dass zu der ersten Hauptkomponente pcs[:,0], die eine Linearkombination
aller Eigenschaften der Weine ist, die 5., 6. und 11. Eigenschaften
(0-indiziert, also “total phenols”, “flavanoids” und
“OD280/OD315”) mit den (betragsmäßig) größten Gewichten beitragen. Diese Hauptkomponente erklärt
bereits ca. 36 % der Varianz der Daten. Mit der zweiten Hauptkomponente
zusammen können ca. 55 % der Varianz erklärt werden. Die Varianzanteile
können wir mit dem folgenden Code visualisieren:
fig1, ax1 = plt.subplots(figsize=(8, 6))
ax1.set_xticks(range(1, 14, 2))
ax1.set_xlabel('principal component index')
ax1.set_ylabel('explained variance')
ax1.bar(range(1, 14), expl_var, color='tab:blue')
ax1.plot(range(1, 14), np.cumsum(expl_var), 'o-', c='tab:orange')
fig1.tight_layout()
plt.show()
Hier haben wir die Funktion np.cumsum verwendet, um die kumulierten
Summen der Varianzanteile zu berechnen. Der resultierende Plot sieht wie folgt aus:
Da wir Datenpunkte in 2D leicht visualisieren können, plotten wir die Projektion der Datenpunkte auf die ersten beiden Hauptkomponenten:
fig2, ax2 = plt.subplots(figsize=(8, 6))
ax2.set_aspect('equal')
ax2.set_xlabel(f'PC 1 ({expl_var[0]:.2f})')
ax2.set_ylabel(f'PC 2 ({expl_var[1]:.2f})')
scat = ax2.scatter(proj[:, 0], proj[:, 1])
fig2.tight_layout()
plt.show()
Aufgrund der Standardisierung der Datenpunkte auf die Einheitsvarianz
ist es sinnvoll, die Hauptkomponenten gleichermaßen
skaliert zu plotten. Aus diesem Grund haben wir die Methode set_aspect('equal')
auf die Achsenobjekte angewendet. Der resultierende Plot sieht wie folgt aus:
Da die ersten beiden Hauptkomponenten bereits ca. 55 % der Varianz der Daten erklären, können wir davon ausgehen, dass wichtige Strukturen des Datensatzes in dieser 2D-Projektion erhalten sind. In diesem Plot erkennen wir aber zunächst nur einen Halbkreis an Punkten, sowie ein “Loch” in der Mitte. Um die Struktur der Datenpunkte in dieser Projektion besser zu verstehen, können wir die Datenpunkte gemäß den Rebsorten, die wir für die PCA nicht verwendet haben, einfärben:
CATEGORY_COLORS = ['#66c2a5', '#fc8d62', '#8da0cb']
colors = [CATEGORY_COLORS[c] for c in categories]
scat.set_color(colors)
for c, l in zip(CATEGORY_COLORS, CATEGORY_LABELS):
ax2.scatter([], [], c=c, label=l)
ax2.legend()
plt.show()
Anstatt einen neuen Plot zu erstellen, haben wir die Farben der Datenpunkte
mit der Methode set_color des Plot-Objekts geändert. Und um eine Legende
anzeigen zu lassen, haben wir drei leere Scatter-Plots mit passenden Farben
und Labels erstellt. Der resultierende Plot sieht wie folgt aus:
Wir erkennen jetzt deutlich, dass die Datenpunkte, bis auf wenige Ausnahmen, in der 2D-Projektion entsprechend ihrer Sorten gut voneinander getrennt sind. Wir haben also eine Darstellung gefunden, in welcher wir die Rebsorten anhand der physikalischen und chemischen Eigenschaften der Weine leicht unterscheiden (d.h. klassifizieren) könnten.
In diesem Abschnitt haben wir gesehen, dass die Kooridnaten der Datenpunkte in der Basis der Features vollständig bekannt sein müssen, um die PCA durchzuführen. Bei Messdaten ist diese Voraussetzung in der Regel erfüllt, aber was ist, wenn wir Daten mit sehr vielen Features vorliegen haben, z.B. Bilder? Ein kleines Bild mit Pixeln hat bereits 10000 Features, und ein hochauflösendes Bild mit Pixeln hat sogar eine Millionen Features. In diesem Fall würde die Durchführung der PCA auf die Datenpunkte in der Basis der Pixelwerte sehr viel Resourcen benötigen. Es wäre in diesem Fall effizienter, wenn wir den Abstand zwischen den Datenpunkten für die PCA verwendet werden könnten, für den wir nur einen Skalar für jedes Paar von Datenpunkten berechnen müssten. Eine Abstandsmetrik kann auch dann hilfreich sein, wenn keine wirklich sinnvollen Koordinaten für die Datenpunkte vorliegen, wie z.B. bei Texten oder chemischen Verbindungen.
Tatsächlich lassen sich die Hauptkomponenten allein aus solchen Abständen bestimmen. Eine Realisierung bietet die Methode der Hauptkoordinatenanalyse (engl. Principal Coordinate Analysis, PCoA).
Übung
Aufgabe 3: PCA mit Eigengesichtern
In der Vorlesung haben Sie gelernt, dass die Hauptkomponentenanalyse (PCA) eng mit der Singulärwertzerlegung verwandt ist. Nutzen Sie das fünfte und sechste Eigengesicht, d.h. die fünfte und sechste Spalte der Matrix aus der vorherigen Aufgabe, als Basisvektoren für die PCA des Yale B Datensatzes und projizieren Sie alle Bilder der fünften und siebten Person auf diese Basisvektoren. Plotten Sie die Projektionen der Bilder in einem Scatterplot und färben Sie die Punkte nach der Person, zu der das Bild gehört. Was beobachten Sie?
Hauptkoordinatenanalyse
Wie im letzten Kapitel beschrieben, ist die Hauptkoordinatenanalyse (engl. principal Coordinate Analysis, PCoA) ein Verfahren, welches die Hauptkomponenten des Datensatzes aus den Abständen zwischen den Datenpunkten bestimmt.
Theoretische Grundlagen
Damit wir am Ende die PCA durchführen können, benötigen wir eine Koordinatendarstellung der Datenpunkte . Nehmen wir zuerst an, dass wir eine solche Koordinatendarstellung durch eine magische Kraft erhalten haben. Dann können wir die an der zentrierten Datenmatrix die SVD durchführen () und erhalten die Projektion auf die Hauptkomponenten durch .
Betrachten wir nun die Gram-Matrix der zentrierten Datenmatrix , die durch gegeben ist. Setzen wir die SVD von ein, so erhalten wir ganz nach Gl. (4.7). Die Projektion der Datenpunkte auf die Hauptkomponenten kann also aus der Eigenwertzerlegung der Gram-Matrix berechnet werden als .
Setzen wir nun Gl. (4.8) in die Definition der zentrierten Gram-Matrix ein, so erhalten wir wo wir die Gram-Matrix der unzentrierten Datenmatrix als definiert haben. Dieser Prozess wird als Double Centering bezeichnet, da wir von der Matrix sowohl die Zeilen- als auch die Spaltenmittelwerte durch und abziehen, und dann den Mittelwert der gesamten Matrix, der doppelt abgezogen wurde, durch wieder hinzufügen. Die Matrix hat also 0 als Spalten- und Zeilenmittelwert.
Das ist zwar schön und gut, dass wir aus den unverarbeiteten Datenkoordinaten die Hauptkomponenten berechnen können, aber wie müssen erst (ohne Magie) die Koordinaten erhalten. Wir betrachten nun das was wir haben: die Abstände zwischen den Datenpunktpaaren. Diese lassen sich in einer symmetrischen -Matrix mit den Elementen speichern, wobei den Abstand zwischen den Datenpunkten und angibt. Des Weiteren nehmen wir an, dass die euklidischen Abstände zwischen den Datenpunkten gegeben sind. Damit gilt wobei wir den Kosinussatz verwendet haben. Das Skalarprodukt der zentrierten Datenpunkte und ist gerade das Element der zentrierten Gram-Matrix .
Wenn wir die Matrix der quadrierten Abstände durch definieren, so unterscheidet sich von der zentrierten Gram-Matrix nur durch einen Zeilen- und einen Spaltenmittelwert. Führt man das Double Centering auf durch, so erhält man die Matrix : Das ist genau die “magische Kraft”, die wir benötigen, um die Abstände in Koordinaten umzuwandeln. Es ergibt sich der folgende Algorithmus:
- Berechne die Matrix der quadrierten Abstände.
- Führe das Double Centering auf durch.
- Berechne die Eigenwertzerlegung von als .
- Berechne projizierten Koordinaten auf die Hauptkomponenten mit . Dieser Algorithmus wird als Principal Coordinate Analysis (PCoA) bezeichnet.
Damit ist PCoA äquivalent zur PCA, wenn der Abstand zwischen den Datenpunkten euklidisch ist. Verwendet man aber eine andere Abstandsmetrik, so liefert die PCoA andere Projektionen der Datenpunkte als die PCA. In diesem Fall ist die erhaltene Projektion oft eine gute Approximation der opti
Es sei noch angemerkt, dass die PCoA zu einer Familie von Verfahren gehört, die als
Multidimensionale Skalierung
Die Multidimensionale Skalierung (engl. Multidimensional Scaling, MDS) versucht, eine Koordinatendarstellung von Datenpunkten in Dimensionen zu finden, so dass die Abstände zwischen den Datenpunkten möglichst gut erhalten bleiben. Sei also der Abstand zwischen dem -ten und -ten Datenpunkt in den ursprünglichen Koordinaten durch und ihre Koordinaten im -dimensionalen Raum durch und gegeben. Dann wird der Stress-Wert durch die MDS minimiert. Hier wurde die genaue Abstandsmetrik für die Berechnung von und die Norm für die Berechnung der Distanz zwischen den Koordinaten und nicht angegeben, da die MDS für beliebige Metriken und Normen definiert werden kann.
Streng genommen ist die PCoA keine MDS, auch wenn die in diesem Kontext als Klassische MDS (CMDS) bezeichnet wird. Es liegt daran, dass die PCoA den Strain-Wert minimiert. Diese Funktion ist nicht äuquivalent zum Stress-Wert der MDS. Aber weil die Idee des Strain-Werts sehr ähnlich zum Stress-Wert ist, wird die PCoA oder die CMDS oft als eine variante der MDS betrachtet.
Implementierung
Für die Implementierung der PCoA nehmen wir ein Beispieldatensatz ohne Koordinaten, die auf dem ersten Blick sinnvoll erscheinen: der GDB-9 Datensatz, bestehend aus 133885 kleinen organischen Molekülen aus den Atomen H, C, N, O, F bis zu einer Größe von 9 schweren Atomen. Damit die Anzahl der Datenpunkte nicht zu groß wird, wählen wir nur eine Untermenge dieses DAtensatztes aus: alle Moleküle aus den Atomen H, C, N, O mit maximal 5 schweren Atomen. Das liefert uns einen Datensatz mit 177 Molekülen, der hier heruntergeladen werden kann.
Die SDF (Structure Data File) stellt eine Sammlung von Molekülen in Format der MDL Molfile dar. Ein Beispielmolekül aus der Datei sieht wie folgt aus:
gdb_4
-OEChem-03231823253D
C2H2
4 3 0 0 0 0 0 0 0999 V2000
0.5995 0.0000 1.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
-0.5995 0.0000 1.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
-1.6616 0.0000 1.0000 H 0 0 0 0 0 0 0 0 0 0 0 0
1.6616 0.0000 1.0000 H 0 0 0 0 0 0 0 0 0 0 0 0
1 2 3 0 0 0 0
1 4 1 0 0 0 0
2 3 1 0 0 0 0
M END
Die Moleküle hier werden durch die Koordinaten ihrer Atome beschrieben. Für die Konvinienz sind auch die kovalenten Bindungen zwischen den Atomen sowie den Bindungstypen gegeben. Im obigen Beispiel sehen wir, dass es zwischen Atom 1 (C) und Atom 2 (C) eine Bindung vom Typ 3 (Dreifachbindung) gibt, und zwischen Atom 1 (C) und Atom 4 (H) sowie Atom 2 (C) und Atom 3 (H) Bindungen vom Typ 1 (Einfachbindung) existieren. Wir können die Informationen der vorhandenen kovalenten Bindungen nutzen, um ein Molekül darzustellen.
Als erstes importieren wir wieder die benötigten Bibliotheken:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.offsetbox import AnnotationBbox, OffsetImage
from rdkit import Chem
from rdkit.Chem import Draw
Das Modul rdkit wird benötigt, um die Moleküle aus der SDF-Datei zu lesen
und später darzustellen. Dieses kann mit dem Befehl
mamba install -c conda-forge rdkit
installiert werden.
Danach definieren wir die konstante DATASET als Pfad zur SDF-Datei und ein
Dictionary BOND_TYPES mit den Bindungstypen, die wir berücksichtigen wollen:
DATASET = 'gdb9_subset_5.sdf'
BOND_TYPES = {
'CC1': 0, 'CC2': 1, 'CC3': 2, 'CC4': 3,
'CN1': 4, 'CN2': 5, 'CN3': 6, 'CN4': 7,
'CO1': 8, 'CO2': 9, 'CO3': 10, 'CO4': 11,
'NN1': 12, 'NN2': 13, 'NN3': 14, 'NN4': 15,
'NO1': 16, 'NO2': 17, 'NO3': 18, 'NO4': 19,
'OO1': 20, 'OO2': 21, 'OO3': 22, 'OO4': 23,
}
Wir haben hier jeden Bindungstyp durch die Symbole der beiden beteiligten Atome (in alphabetischer Reihenfolge) mit einer Zahl kodiert. Die Molfile-Spezifikation schreibt folgende Kodierung für die Bindungstypen vor:
| Kodierung | Bindungstyp |
|---|---|
| 1 | Einfachbindung |
| 2 | Doppelbindung |
| 3 | Dreifachbindung |
| 4 | Aromatische Bindung |
| 5 | Einfach- oder Doppelbindung |
| 6 | Einfachbindung oder Aromatische Bindung |
| 7 | Doppelbindung oder Aromatische Bindung |
| 8 | beliebige Bindung |
Wir verwenden hier die Kodierungen 2, 3 und 4 wie in der Molfile-Spezifikation, aber die Kodierung 1 für die Einfachbindung und alles andere, was nicht 2, 3 oder 4 ist aus Einfachheit.
Unser Dictionary in diesem Fall berücksichtigt also alle Bindungstypen zwischen schweren Atomen (C, N, O) und ignoriert Bindungen mit Wasserstoffatomen. Das Dictionary vergibt für jede Bindung eine Zahl, die später als Index für ein Array verwendet wird, das die Anzahl der Bindungen jedes Typs speichert.
Danach definieren wir die Funktion get_fingerprint, die die oben
beschriebene Darstellung eines Moleküls implementiert:
def get_fingerprint(mol):
fingerprint = np.zeros(len(BOND_TYPES), dtype=np.int32)
for bond in mol.GetBonds():
sym1 = bond.GetBeginAtom().GetSymbol()
sym2 = bond.GetEndAtom().GetSymbol()
rd_btype = bond.GetBondType()
if rd_btype == Chem.rdchem.BondType.DOUBLE:
btype_num = 2
elif rd_btype == Chem.rdchem.BondType.TRIPLE:
btype_num = 3
elif rd_btype == Chem.rdchem.BondType.AROMATIC:
btype_num = 4
else:
btype_num = 1
btype = ''.join(sorted([sym1, sym2])) + str(btype_num)
if btype in BOND_TYPES:
fingerprint[BOND_TYPES[btype]] += 1
return fingerprint
Hierbei wird zuerst ein Nullarray fingerprint mit der Länge der
Anzahl der Bindungstypen in unserem Dictionary BOND_TYPES erstellt. Da
dieses Array die Anzahl der Bindungen von jedem Typ speichern soll,
wählen wir np.int32 als Datentyp. Danach iterieren wir über die Bindungen
mit der GetBonds()-Methode des Molekülobjektes vom Modul rdkit. In jeder
Iteration extrahieren wir die Symbole der beteiligten Atome und setzen unsere
Kodierung der Bindungstyp durch den if-Block. Am Ende sortieren wir die
Symbole der Atome alphabetisch und hängen die Kodierung daran. Das liefert
uns eine Zeichenkette vom gleichen Stil wie die im Dictionary BOND_TYPES.
Falls der Bindungstyp im Dictionary vorhanden ist, erhöhen wir den
entsprechenden Eintrag im Array fingerprint um 1. Am Ende geben wir das
Array zurück.
Nun können wir die Moleküle aus dem Datensatz einlesen und ihre
Repräsentationen mit der Funktion get_fingerprint berechnen:
mols = [mol for mol in Chem.SDMolSupplier(DATASET)]
fingerprints = np.array([get_fingerprint(mol) for mol in mols])
Danach erfolgt die Berechnung der Abstandsmatrix. In diesem Fall ist es sinnvoll, die Anzahl der unterschiedlichen Bindungen zwischen den Molekülen als Abstand zu verwenden. Wir bilden also die Differenz der Fingerprint-Arrays und zählen die Absolutwerte der Unterschiede zusammen:
n = len(fingerprints)
distances = np.zeros((n, n))
for i in range(0, n):
for j in range(i + 1, n):
distances[i, j] = np.sum(np.abs(fingerprints[i] - fingerprints[j]))
distances[j, i] = distances[i, j]
Nach dem Initialisieren der Abstandsmatrix distances mit Nullen iterieren
wir die Index i über die Anzahl der Moleküle und die Index j von i + 1
bis zur Anzahl der Moleküle. Es wird also nur die strikte obere Dreiecksmatrix
der Abstandsmatrix berechnet. Das ist aber ausreichend, weil die
Abstandsmatrix symmetrisch ist mit Nullen auf der Diagonalen. Nach der
Berechnung des Abstandes in jeder Iteration setzen wir sowohl das Element
distances[i, j] als auch distances[j, i] auf den berechneten Wert.
Jetzt können wir die PCoA gemäß dem oben beschriebenen Algorithmus durchführen:
c_mat = np.eye(n) - np.ones((n, n)) / n
b_mat = -0.5 * np.linalg.multi_dot([c_mat, distances**2, c_mat])
e, v = np.linalg.eigh(b_mat)
embedding = np.dot(v[:, -2:], np.diag(np.sqrt(e[-2:])))
Bei der Projektion haben wir hier die letzten beiden Eigenwerten und
Eigenvektoren verwendet, da die Eigenwerte aus np.linalg.eigh in
aufsteigender Reihenfolge sortiert sind.
Für die PCoA können wir wie bei der PCA auch eine Art Varianzanteil definieren: Die Heaviside-Funktion liefert eine 1, wenn ihr Argument größer gleich Null ist, und 0 sonst. Deshalb wird in der obigen Formel nur über die positiven Eigenwerte summiert. In diesem Fall wird über 50 % der Varianz durch die ersten beiden Hauptkomponenten erklärt:
eta = np.sum(e[::-1][:2]) / np.sum(e[e > 0])
assert np.isclose(eta, 0.50722839)
print(eta)
Wir können die projizierten Datenpunkte nun visualisieren:
fig, ax = plt.subplots(figsize=(8, 6))
ax.scatter(embedding[:, 1], embedding[:, 0])
ax.set_aspect('equal')
ax.set_xlabel('PC1')
ax.set_ylabel('PC2')
fig.tight_layout()
plt.show()
Das Diagramm sollte wie folgt aussehen:
Weil die PCoA den Abstand zwischen den Datenpunkten möglichst gut erhält,
kann man in der Projektion näherungsweise den Abstand zwischen den Molekülen
als Unähnlichkeitsmaß interpretieren. Das bedeutet, dass Moleküle, die
nahe beieinander liegen, ähnliche Strukturen haben sollten. Im obigen
Diagramm erkennen wir zwar, dass einige Punkte am Rand vereinzelt auftreten
und einige Punkte in der Mitte häufen, aber die Struktur der Moleküle
ist nicht dargestellt. Mit etwas mehr Aufwand lässt sich einen interaktiven
Plot erstellen, der die zugehörige Struktur des Punktes, auf welchem die
Maus gerade steht, anzeigt. Wichtig ist hierbei, dass das scatter-Objekt
als eine Variable gespeichert wird, damit wir für die Interaktivität ihre
Information verwenden können. Sie müssen diesen Teil des Codes nicht
im Detail verstehen, aber es lohnt sich, ihn auszuprobieren:
fig, ax = plt.subplots(figsize=(8, 6))
sc = ax.scatter(embedding[:, 1], embedding[:, 0])
ax.set_aspect('equal')
ax.set_xlabel('PC1')
ax.set_ylabel('PC2')
fig.tight_layout(rect=[0.0, 0.0, 0.9, 0.9])
imagebox = OffsetImage(np.zeros((100, 100, 3)), zoom=0.5)
imagebox.image.axes = ax
ab = AnnotationBbox(
imagebox, (40, 40), xycoords='data',
boxcoords="offset points", arrowprops=dict(arrowstyle="->"),
)
ax.add_artist(ab)
def update_annotation_box(idx):
ab.xy = (embedding[idx, 1], embedding[idx, 0])
mol = mols[idx]
Chem.rdDepictor.Compute2DCoords(mol)
img = Draw.MolToImage(mol, size=(100, 100), wedgeBonds=False)
ab.offsetbox.set_data(img)
def hover(event):
vis = ab.get_visible()
if event.inaxes == ax:
cont, ind = sc.contains(event)
if cont:
update_annotation_box(ind['ind'][0])
ab.set_visible(True)
fig.canvas.draw_idle()
else:
if vis:
ab.set_visible(False)
fig.canvas.draw_idle()
fig.canvas.mpl_connect('motion_notify_event', hover)
plt.show()
Die Interpretation der resultierenden Abbildung ist dem Lesenden überlassen.
Übung
Aufgabe 4: PCoA und Molekulare Fingerprints
In Kapitel 4.4. haben Sie gesehen, wie die Hauptkoordinatenanalyse (PCoA) dazu verwendet werden kann, um eine niedrigdimensionale Darstellung eines Datensatzes basierend auf Distanzen zwischen den Datenpunkten zu erhalten. Dabei ist die Wahl der Repräsentation der Datenpunkte entscheidend für die Qualität der Darstellung.
(a) Erweitern der Molekularen Fingerprints
Ergänzen Sie Ihre Implementierung der PCoA des GDB-9 Datensatzes, sodass die Fingerprints der Moleküle
auch Bindungen von schweren Atomen zu Wasserstoffatomen enthalten. Erweitern Sie dazu lediglich das Dictionary
BOND_TYPES und verwenden Sie die rdkit-Funktion
Chem.AddHs(mol) um Wasserstoffatome hinzuzufügen.
Wie interpretieren Sie die Ergebnisse?
(b) Entwerfen von neuen Fingerprints
Wie Sie in (a) gesehen haben, ist das Ergebis der PCoA stark abhängig von der Wahl der Repräsentation der Datenpunkte. Nutzen Sie Ihre chemische Intuition, um weitere geeignete Fingerprints zu entwerfen und testen Sie sie anhand des GDB-9 Datensatzes.
Lineare Gleichungssysteme
Viele Probleme in der Naturwissenschaft, Technik und darüber hinaus lassen sich durch lineare Gleichungssysteme beschreiben. Solche Systeme lassen sich kompakt in Matrixschreibweise darstellen als mit der Koeffizientenmatrix , dem Vektor der Unbekannten und dem Vektor der rechten Seite , welcher auch als Inhomogenität bezeichnet wird. Die Anzahl der Gleichungen entspricht der Anzahl der Zeilen von , während die Anzahl der Unbekannten der Anzahl der Spalten von entspricht.
Das Standardverfahren zur Lösung linearer Gleichungssysteme ist der Gauß-Algorithmus. Die übliche Implementierung funktioniert allerdings nur für Gleichungssysteme mit eindeutiger Lösung. Für unterbestimmte Systeme mit unendlich vielen Lösungen ist der Gauß-Algorithmus numerisch instabil und für überbestimmte Systeme ohne Lösungen liefert er uns keine nützlichen Informationen.
Die formale Lösung eines eindeutigen Systems mit ist , wobei die Inverse von bezeichnet. Mit exakter Arithmetik ist diese Lösung identisch zur der aus dem Gauß-Algorithmus. Für singuläre oder gar nicht-quadratische Matrizen ist die Inverse aber nicht definiert und diese Lösung daher nicht anwendbar. Es wäre doch schön, wenn wir eine Operation hätten, die einige Eigenschaften der Inversen erfüllt, aber für alle Matrizen definiert ist. Solche Operationen nennen sich Pseudoinversen und ein bekannter Vertreter ist die Moore-Penrose-Pseudoinverse, die wir im Folgenden kennenlernen wollen.
Theoretische Grundlagen
Ist eine invertierbare Matrix auch diagonalisierbar mit der Eigenwertzerlegung , so kann die ihre Inverse durch berechnet werden, wobei die Diagonalmatrix der invertierten Eigenwerte ist. Die Richtigkeit dieser Formel lässt sich leicht durch and zeigen. Da die Singulärwertzerlegung einer Verallgemeinerung der Eigenwertzerlegung für beliebige Matrizen ist, können wir überlegen, eine Verallgemeinerung der Inversen über die Singulärwertzerlegung zu definieren.
Wir definieren die Moore-Penrose-Pseudoinverse (MP-Pseudoinverse) einer Matrix als mit der pseudoinversen Diagonalmatrix , die durch Transponieren und Invertieren der nicht-verschwindenden Singulärwerte von entsteht. Es kann gezeigt werden, dass die MP-Pseudoinverse die sog.
erfüllt. Umgekehrt definieren diese Bedingungen die MP-Pseudoinverse eindeutig. Ist die Matrix invertierbar, so ist die MP-Pseudoinverse identisch zur Inversen, also .Betrachten wir nun ein überbestimmtes Gleichungssystem mit , und , welches i.A. keine Lösung besitzt. Dann gilt das Theorem also liefert die Lösung, die den quadratischen Fehler beider Seite des Gleichungssystems minimiert.
Beweis
Wir starten mit der Differenzvektor für ein beliebiges und addieren 0:
und berechnen dann ihre euklidische Norm: wo und die Skalarprodukte zwischen den beiden Vektoren und sind. Diese können durch Einsetzen von berechnet werden: wobei wir für die 3. Zeile Gl. (4.12) und für die 5. Zeile Gl. (4.10) eingesetzt wurden.
Aus der Symmetrie des Skalarprodukts folgert man . Der ursprüngliche Ausdruck vereinfacht sich nun zu Also ist die euklidische Norm des Differenzvektors für beliebiges immer größer als . Damit minimiert den quadratischen Fehler beider Seite des Gleichungssystems.
Für ein unterbestimmtes Gleichungssystem mit , und , die unendlich viele Lösungen besitzt, gilt das Theorem also liefert die Lösung mit der kleinsten euklidischen Norm.
Implementierung
Wir implementieren die MP-Inverse am Beispiel eines überbestimmten Gleichungssystems:
Nach dem Importieren von NumPy
import numpy as np
definieren wir die Funktion pinv:
def pinv(mat, rcond=1e-12):
u, s, vh = np.linalg.svd(mat)
nrows, ncols = mat.shape
s_inv = np.zeros((ncols, nrows))
for i in range(0, min(nrows, ncols)):
if s[i] > rcond:
s_inv[i, i] = 1 / s[i]
return vh.T @ s_inv @ u.T
Nach der SVD der Ausgangsmatrix initiieren wir die invertierte Matrix
der Singulärwerte s_inv mit der Dimension der Transposition von s.
Danach iterieren wir über und invertieren die
Singulärwerte, die ungleich null sind. Da die Fließkommazahlen nicht exakt
sind, setzen wir einen Schwellenwert rcond. Singulärwerte, die kleiner als
diese Schwelle liegen, werden als null behandelt und nur größere
Singulärwerte werden invertiert. Am Ende gibt die Funktion die MP-Inverse
gemäß Gl. (4.9) zurück.
Nun können wir das angegebene Gleichungssystem definieren:
a_mat = np.array([
[1, 1],
[2, 1],
[3, 1],
[4, 1],
])
b_vec = np.array([4.0, 3.5, 4.5, 6.5])
und diese mit der MP-Inversen lösen:
a_pinv = pinv(a_mat)
x0 = a_pinv @ b_vec
Wir erhalten die folgenden Ergebnisse:
print(x0)
assert np.isclose(x0[0], 0.85)
assert np.isclose(x0[1], 2.50)
Einige Einträge der Matrix dürfte etwas komisch erscheinen, wie z.B. die zweite Spalte, die nur Einsen enthält. Wir schreiben diese Matrixgleichung nun aus: oder mit familiären Variablennamen
Es soll jetzt unschwer erkennbar sein, dass dieses Gleichungssystem das Problem darstellt, eine Gerade zu finden, die durch die 4 Punkte , , und verläuft. Mit einwenig Vorstellung wird es einem klar, dass so eine Gerade nicht existiert. Die Lösung durch die MP-Inverse ist daher diejenige Gerade, die den quadratischen Fehler zwischen den Punkten und der Geraden minimiert. Falls einem diese Idee bekannt vorkommt, dann ist das kein Zufall. Das ist gerade die lineare Regression mit Methode der kleinsten Quadrate, die wir bereits in Kapitel 1.2 kenngelernt haben.
Die MP-Inverse liefert uns eine weitere Möglichkeit, die Parameter der (multi-)lineare Regression zu berechnen: Für die Datenpunkte mit , sowie das Modell , sind die optimalen Parameter und durch die Least-Squares-Lösung des Gleichungssystems gegeben, also
Maschinelles Lernen
Maschinelles Lernen (engl. machine learning, ML) wird häufig als ein Teilgebiet der Künstlichen Intelligenz (engl. artificial intelligence, AI) betrachtet, welches sich mit der Entwicklung von Algorithmen beschäftigt, die es uns erlauben, aus Daten zu lernen und Vorhersagen zu treffen. Dabei wollen wir die grundlegenden Muster und Strukturen in den Daten erkennen, um diese für zukünftige Vorhersagen zu nutzen. Als Künstliche Intelligenz wiederum verstehen wir diejenigen Systeme, die in der Lage sind, Aufgaben zu erfüllen, die sonst menschliche Intelligenz erfordern würden. Dazu gehören beispielsweise das Erkennen von Sprache, das Verstehen von Texten oder die Identifikation von Objekten in Bildern.
Die Anwendung von ML-Methoden ist in den letzten Jahren immer populärer geworden und hat in unzähligen Bereichen Einzug gehalten, auch in der chemischen Forschung. Wir möchten jedoch zu Beginn anmerken, dass wir hier nur einen sehr groben Überblick über das Thema geben können. Maschinelles Lernen ist ein interdisziplinäres Forschungsgebiet, das Methoden aus der Statistik, der Informatik und der Mathematik vereint und daher sehr umfangreich ist. Vielmehr möchten wir Ihnen zeigen, wie Sie mit Ihren neu erworbenen Python-Kenntnissen einfache ML-Modelle implementieren und anwenden können.
Um zu verstehen, was maschinelles Lernen von anderen, Ihnen bereits bekannten Algorithmen unterscheidet, betrachten wir das folgende Schaubild:
Der herkömliche Weg einen Computer nützliche Aufgaben erfüllen zu lassen, besteht darin, ihm eine Reihe von Regeln zu übergeben. Diese Regeln werden von einem Programmierer festgelegt und in Form eines Programms umgesetzt. Für einen gegebenen Input soll der Computer diese Regeln dann befolgen, um ein bestimmtes Resultat zu erzeugen.
Im Gegensatz dazu lernt ein ML-Algorithmus aus Daten. Anstatt ihm Regeln vorzugeben, wird ihm eine Menge von Daten gegeben, die aus den Inputs und erwarteten Outputs bestehen. Der Algorithmus lernt dann aus diesen Daten, wie er den Input in den Output umwandeln kann. Das bedeutet, dass der Algorithmus selbstständig Regeln aus den Daten extrahiert, anstatt dass diese ihm vorgegeben werden. Diese Regeln werden in Form von Modellen repräsentiert, die aus den Daten gelernt werden. Wir werden in den folgenden Abschnitten einen Programmierstil kennenlernen, der es uns erlaubt, solche Modelle zu erstellen und anzuwenden.
Man unterscheidet im Allgemeinen zwischen zwei oder mehr Arten von ML-Verfahren, die sich durch ihre Art des Lernens aus den Daten auszeichnen:
-
Überwachtes Lernen (engl. supervised learning): Hierbei werden dem Algorithmus Daten gegeben, die aus Inputs und den dazugehörigen Outputs bestehen. Der Algorithmus lernt dann, wie er die Inputs in die Outputs umwandeln kann. Ein Beispiel für ein solches Problem ist die Vorhersage der Synthetisierbarkeit eines Moleküls basierend auf dessen Struktur und weiteren Eigenschaften.
-
Unüberwachtes Lernen (engl. unsupervised learning): Hierbei werden dem Algorithmus nur die Inputs gegeben, ohne dass die dazugehörigen Outputs bekannt sind. Der Algorithmus lernt dann, wie er die Inputs in sinnvolle Gruppen einteilen kann. Ein Beispiel für ein solches Problem ist die Identifikation von Gruppen von Molekülen mit ähnlichen Eigenschaften.
Manchmal werden auch weitere Arten von Lernverfahren unterschieden, wie z.B. verstärkendes Lernen (engl. reinforcement learning) oder semi-überwachtes Lernen (engl. semi-supervised learning). Wir werden uns in diesem Kapitel jedoch auf die beiden oben genannten Arten beschränken.
Versuchen Sie, für die bereits in diesem Kurs behandelten Algorithmen zu überlegen, ob sie eher dem klassischen Programmieransatz oder dem maschinellen Lernen (überwacht oder unüberwacht) entsprechen. Sie werden feststellen, dass einige Ansätze dem maschinellen Lernen zugeordnet werden können, sodass Sie bereits jetzt von sich behaupten können, ein wenig über ML zu wissen. Herzlichen Glückwunsch!
Überwachtes Lernen
Liegen uns Datenpaare vor, wobei die Eingabe und die Ausgabe darstellt, so ist es unser Ziel, eine approximative Abbildung zu finden, die die Eingabe auf die Ausgabe abbildet, d.h. für alle . Man bezeichnet auch als Labels oder Targets. Unser Modell ist in der Regel parametrisiert, d.h. es gibt eine Menge von Parametern , die die Abbildung definieren. Demnach wollen wir die Parameter so wählen, dass der Fehler zwischen der tatsächlichen Ausgabe und der approximierten Ausgabe minimiert wird. Dieses Lernen wird als überwachtes Lernen bezeichnet, da wir für jede Eingabe kennen und somit den Fehler direkt berechnen können.
Gemäß den obigen Überlegungen können wir festhalten, dass jedes ML-Modell, welches auf überwachtem Lernen basiert, die folgenden Komponenten enthält:
- Modell: Die Funktion , die die Eingabe auf die Ausgabe abbildet.
- Verlustfunktion: Die Funktion, die den Fehler zwischen der tatsächlichen Ausgabe und der approximierten Ausgabe misst.
- Optimierungsverfahren: Der Algorithmus, der die Parameter des Modells so anpasst, dass der Fehler minimiert wird.
Dieses Setting lässt sich tatsächlich auch auf das unüberwachte Lernen übertragen, wobei die Verlustfunktion durch eine allgemeine Objektivfunktion ersetzt wird, die es zu maximieren oder minimieren gilt.
Regression
Für den Fall, dass die Labels kontinuierlich sind, also z.B. reelle Zahlen annnehmen, spricht man von Regression. Den einfachsten Fall, die lineare Regression mit , haben Sie bereits ausführlich in Kapitel (1.2) kennengelernt und diskutiert. Wir werden daher nur kurz darauf eingehen und die lineare Regression in den Kontext des überwachten Lernens einordnen.
Zunächst möchten wir anmerken, dass wir bisher lediglich Inputs betrachtet haben, die nur eine Dimension besitzen, also für . Diese stellten beispielsweise die Konzentrationen von Methylenblau, welche auf die Absorbanz abgebildet wurden, dar. In der Praxis haben wir jedoch oft mit mehrdimensionalen Inputs zu tun, welche Features besitzen. In den vorherigen Abschnitten haben wir zur Darstellung dieser Daten die Datenmatrix kennengelernt, welche die Inputs in den Zeilen und die Features in den Spalten speichert. Die lineare Regression für mehrdimensionale Inputs lautet dann:
was wir als Skalarprodukt zwischen dem Vektor und dem Inputvektor schreiben können:
Unter Berücksichtigung aller Datenpunkte durch die Matrix ergeben sich die Vorhersagen aller Inputs durch eine Matrix-Vektor-Multiplikation:
Da wir die Vorhersage des Modells als gewichtete Summe der Features berechnen, bezeichnet man die Parameter des Modells auch als Gewichte (engl. weights) und Bias . Das -te Gewicht gibt dabei an, wie stark das -te Feature in die Vorhersage eingeht. Als Verlustfunktion wählt man in der Regel die Summe der quadratischen Fehler, welche Sie bereits kennen.
Um die Modellparameter zusammenzufassen, kann auch als ein zusätzliches Gewicht eingeführt werden, sodass . Dazu muss der Inputvektor um einen konstanten Wert erweitert werden, sodass . Überprüfen Sie, dass die lineare Regression in diesem Fall äquivalent zur obigen Formulierung ist.
Gemäß den oben diskutierten Komponenten eines ML-Modells fehlt uns nun nur noch die Angabe eines Optimierungsverfahrens, um die Modellparameter so zu wählen, dass der Fehler zwischen den Vorhersagen und den tatsächlichen Labels minimiert wird. In der ersten Übung haben Sie gesehen, dass dieses Problem für eine analytische Lösung besitzt. Auch für den multi-dimensionalen Fall existiert eine analytische Lösung,
was Sie leicht durch Berechnen und Nullsetzen des Gradienten der Verlustfunktion nach (hier ) überprüfen können.
Beweis
Die Verlustfunktion der kleinsten Quadrate lautet
wobei wir in integriert haben. Der Gradient der Verlustfunktion nach ist
Setzen wir den Gradienten null, erhalten wir
Multiplizieren wir die Gleichung von links mit , so erhalten wir die Lösung
Die lineare Algebra besagt, dass die Matrix invertierbar ist, wenn die Spalten von linear unabhängig sind. Für den Fall ist dies sehr wahrscheinlich, und wir sprechen von unabhängigen Features.
Ist die Matrix nicht invertierbar, so liefert uns die Moore-Penrose-Pseudoinverse,
die optimale Lösung mit minimaler Norm.
Regression am Wine Quality Dataset
Um den Datensatz, den wir im Folgenden verwenden werden, zu erkunden, nutzen wir eine der bekanntesten
Bibliotheken für die Datenanalyse und -manipulation in Python: pandas.
Abgeleitet vom Begriff Panel Data und basiert auf numpy führt pandas eine nützliche Datenstruktur aus der
Programmiersprache R in Python ein: Das DataFrame, welches als eine Tabelle mit Zeilen und Spalten
interpretiert werden kann.
Nachdem Sie pandas mit
mamba install -c conda-forge pandas
installiert haben, können Sie das Modul importieren:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
Mit nur einem einzigen Befehl importieren wir den Wine Quality Datensatz aus dem Internet und speichern ihn in
einem DataFrame-Objekt:
url = 'https://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/winequality-red.csv'
df = pd.read_csv(url, sep=';')
print(type(df)) # <class 'pandas.core.frame.DataFrame'>
print(df.shape) # (1599, 12)
print(df.head())
Mit der Methode shape können Sie die Dimensionen des Datensatzes abfragen (also die Anzahl der Datenpunkte und
Features), und mit head() können Sie sich die ersten Zeilen des Datensatzes anzeigen lassen. Dabei sollte
Ihnen die folgende Tabelle angezeigt werden:
| fixed acidity | volatile acidity | citric acid | residual sugar | chlorides | free sulfur dioxide | total sulfur dioxide | density | pH | sulphates | alcohol | quality | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 7.4 | 0.7 | 0 | 1.9 | 0.076 | 11 | 34 | 0.9978 | 3.51 | 0.56 | 9.4 | 5 |
| 1 | 7.8 | 0.88 | 0 | 2.6 | 0.098 | 25 | 67 | 0.9968 | 3.2 | 0.68 | 9.8 | 5 |
| 2 | 7.8 | 0.76 | 0.04 | 2.3 | 0.092 | 15 | 54 | 0.997 | 3.26 | 0.65 | 9.8 | 5 |
| 3 | 11.2 | 0.28 | 0.56 | 1.9 | 0.075 | 17 | 60 | 0.998 | 3.16 | 0.58 | 9.8 | 6 |
| 4 | 7.4 | 0.7 | 0 | 1.9 | 0.076 | 11 | 34 | 0.9978 | 3.51 | 0.56 | 9.4 | 5 |
Der Wine Quality Datensatz enthält Daten von ca. 1600
Rot- und Weißweinen aus dem Norden Portugals. Die Features beschreiben verschiedene physikalische und chemische
Eigenschaften der Weine, wie beispielsweise den Alkoholgehalt, den pH-Wert oder den Zitronensäuregehalt.
pandas besitzt neben einer Vielzahl von Methoden, um den Datensatz zu analysieren, auch die Möglichkeit,
Daten zu filtern, zu gruppieren oder zu visualisieren. So kann mit der Methode scatter_matrix ein
Streudiagramm der Features erstellt werden, um Korrelationen zwischen den Features zu erkennen,
# Plot correlation matrix for the first four features
features = df.columns[:4]
pd.plotting.scatter_matrix(df[features], figsize=(10, 10))
was Ihnen die folgende Abbildung anzeigen sollte:
Neben den Features besitzt jeder Wein eine Qualitätsbewertung zwischen 0 und 10, die als Label oder Target in der
Regression oder Klassifikation verwendet werden kann. Dieses Target wollen wir nun anhand von zwei ausgewählten
Features, alcohol und volatile acidity, regressieren. Dazu extrahieren wir zunächst die Datenmatrix und den Vektor
der Labels aus dem DataFrame, wobei Sie einzelne Spalten des DataFrame mit dem Namen der Spalte als Index abrufen können:
# Define data matrix and labels
X = df[["alcohol", "volatile acidity"]].to_numpy()
y = df["quality"].to_numpy()
# Add a column of ones to the data matrix
X = np.hstack([np.ones((X.shape[0], 1)), X])
Außerdem fügen wir eine Spalte mit Einsen hinzu, um den Bias in den Gewichten zu
integrieren. Dazu definieren wir mit np.ones einen Vektor der Länge mit Einsen und fügen ihn
mit np.hstack als erste Spalte in die Datenmatrix ein. Die optimalen Gewichte
berechnen wir gemäß der obigen Formel:
# Perform linear regression
theta = np.linalg.inv(X.T @ X) @ X.T @ y
print(theta)
Für die zwei ausgewählten Features, sowie den Bias, enthält das Array theta also die optimalen Gewichte.
Wir visualisieren die Vorhersagen des Modells und die tatsächlichen Labels in einem 3D-Plot:
# Make a 3D plot
fig, ax = plt.subplots(subplot_kw={'projection': '3d'})
# Plot alcohol and volatile acidity against quality
ax.scatter(df['alcohol'], df['volatile acidity'], y, c='r', marker='o')
# Plot the linear regression
x = np.linspace(8, 15, 100) # alcohol
y = np.linspace(0, 1.6, 100) # volatile acidity
X, Y = np.meshgrid(x, y)
Z = theta[0] + theta[1] * X + theta[2] * Y
ax.plot_surface(X, Y, Z, alpha=0.5)
# Set labels
ax.set_xlabel('Alcohol')
ax.set_ylabel('Volatile acidity')
ax.set_zlabel('Quality')
ax.view_init(elev=30, azim=45)
fig.tight_layout()
plt.show()
Wie für 2D-Plots, müssen Sie zum Plotten von Datenpunkten in 3D mit matplotlib die entsprechenden Datenpaare
in Form von drei 1D-Arrays übergeben. Die einzelnen Spalten aus dem Datensatz liegen bereits in
dieser Form vor, sodass diese direkt mit scatter geplottet werden können. Die Vorhersagen des Modells
gemäß der linearen Regression stellt jedoch eine kontinuierliche Fläche dar, d.h. wir benötigen ein Grid mit
gleichmäßig verteilten Punkten, für welche wir die Vorhersagen berechnen und darstellen. Dies geschieht
mit der Funktion np.meshgrid,
die Ihnen ein 2D-Array mit den Koordinaten der Punkte im Grid zurückgibt, für welche wir die Vorhersagen
berechnen. Das Plotten der Fläche erfolgt dann mit plot_surface und sollte das folgende Bild ergeben:
Durch Klicken und Ziehen können Sie den Plot drehen und die Vorhersagen des Modells aus verschiedenen Blickwinkeln betrachten.
Klassifikation
Was Sie bei genauerer Betrachtung des Plots sicherlich bemerkt haben, ist, dass die Labels der Datenpunkte diskrete Werte zwischen 3 und 8 annehmen. Im obigen Beispiel haben wir diese Labels als kontinuierliche Werte interpretiert, um die Regression durchzuführen. In der Praxis ist es jedoch oft sinnvoller, solche Probleme als Klassifikation zu betrachten, bei denen die Labels diskrete Klassen repräsentieren. Labels, die zwar diskret, aber geordnet sind, wie im obigen Beispiel, bezeichnet man übrigens als ordinal.
Das abstrakte Ziel der Klassifikation besteht darin, eine Funktion zu finden, die die Eingabe auf eine von Klassen abbildet. Wir werden uns im Folgenden auf den einfachsten Fall der Klassifikation beschränken, die binäre Klassifikation, bei denen die Daten zwei möglichen Klassen zugeordnet werden können. Ein Beispiel für ein solches Problem ist die Vorhersage, ob ein Molekül synthetisierbar ist oder nicht.
Für den Fall, dass die Daten mehr als zwei Klassen aufweisen, kann binäre Klassifikation ebenfalls angewendet werden. Dazu gibt es zwei gängige Verfahren:
-
One-vs-All: Hierbei wird für jede Klasse ein binäres Modell trainiert, das die Daten dieser Klasse von den anderen Klassen unterscheidet. Die Vorhersage erfolgt dann durch das Modell, das die höchste Wahrscheinlichkeit für die gegebene Eingabe liefert.
-
One-vs-One: Hierbei wird für jede mögliche Kombination von zwei Klassen ein binäres Modell trainiert, das die Daten dieser beiden Klassen voneinander unterscheidet. Die Vorhersage erfolgt dann durch das Modell, das die meisten Stimmen für die gegebene Eingabe erhält.
In Übung 4, als wir die Gesichter von zwei Personen auf zwei Hauptkomponenten (Eigenfaces) projiziert haben, ist uns bereits eine Darstellung der Daten in begegnet, die durch eine Gerade getrennt werden könnte. Basierend auf einer solchen Entscheidungsgrenze (engl. decision boundary) wollen nun wir nun ein Modell trainieren, welches möglichst alle Datenpunkte korrekt klassifiziert und auch für neue, unbekannte Datenpunkte eine korrekte Vorhersage treffen kann. Sie können dazu Ihre Implementation aus der Übung verwenden oder die Daten der Gesichter hier herunterladen, wobei die dritte Spalte die Labels der Personen enthält.
Wir zeigen zunächst anhand eines Negativbeispiels, wie eine solche lieare Entschiedungsgrenze zustande kommen kann. Dazu interpretieren wir die Labels wie im obigen Beispiel als kontinuierliche Werte und führen lineare Regression durch. Die (kontinulierlichen) Vorhersagen des Modells könnten dann als Klassen interpretiert werden, indem wir die Werte auf die nächstgelegene Klasse abbilden. Da die Klassen hier durch -1 und 1 repräsentiert werden, bilden wir die Vorhersagen auf die Klasse ab, die dem Vorzeichen der Vorhersage entspricht. Das Modell hat also die Form
Wir nutzen dazu im Grunde den gleichen Code wie im obigen Beispiel:
Code
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
# Load the data
path = './eigenfaces_pca.csv'
df = pd.read_csv(path, sep=';')
# Define data matrix and labels
X = df[["pca1", "pca2"]].to_numpy()
y = df["label"].to_numpy()
# Add a column of ones to the data matrix
X = np.hstack([np.ones((X.shape[0], 1)), X])
# Perform linear regression
theta = np.linalg.inv(X.T @ X) @ X.T @ y
print(theta)
# Make a 3D plot
fig, ax = plt.subplots(subplot_kw={'projection': '3d'})
# Plot the data points, color-coded by the labels
ax.scatter(X[:,1][y == 1], X[:,2][y == 1], y[y == 1], color='blue', label='Person 5')
ax.scatter(X[:,1][y == -1], X[:,2][y == -1], y[y == -1], color='red', label='Person 7')
# Plot the linear regression
x = np.linspace(-5000, 5000, 1000)
y = np.linspace(-5000, 5000, 1000)
X, Y = np.meshgrid(x, y)
Z = theta[0] + theta[1] * X + theta[2] * Y
ax.plot_surface(X, Y, Z, alpha=0.5)
# Plot decision boundary, where Z = 0
ax.contour(X, Y, Z, levels=[0], colors='black', linestyles='dashed')
# Set labels
ax.set_xticklabels([])
ax.set_yticklabels([])
ax.set_xlabel('PCA 5')
ax.set_ylabel('PCA 6')
ax.set_zlabel('Label')
ax.view_init(elev=30, azim=-45)
fig.tight_layout()
plt.show()
Zusätzlich haben wir die Entscheidungsgrenze des Modells
in den Plot eingefügt, die durch die gestrichelte Linie dargestellt wird:
Wenn Sie allerdings den Plot von oben betrachten, werden Sie feststellen, dass die Entscheidungsgrenze nicht optimal ist, da sie nicht alle Datenpunkte korrekt klassifiziert. Die Methode der linearen Regression durch Minimierung der quadratischen Fehler ist also nicht geeignet, um Klassifikationsprobleme zu lösen.
Rosenblatt-Perzeptron
Anstatt die Labels als kontinuierliche Werte zu interpretieren und eine lineare Regression durchzuführen, wäre es sinnvoller, die Gewichte des Models zu lernen, indem die Anzahl der falsch klassifizierten Datenpunkte minimiert wird. Ein Modell, welches die Daten nach einer linearen Projektion auf das Vorzeichen der Vorhersage abbildet, wird auch als Perzeptron bezeichnet. Dem Perzeptron liegt ein einfacher Algorithmus zugrunde, der die Datenpunkte iterativ durchgeht und die Gewichte und anpasst, wenn ein Datenpunkt falsch klassifiziert wurde. Betrachten wir dazu die folgende Verlustfunktion:
wobei die Summe über die Menge der falsch klassifizierten Datenpunkte läuft. Dabei erinnern wir uns daran, dass wir überprüfen können, ob ein Datenpunkt falsch klassifiziert wurde, indem wir die Vorhergesage gemäß Gleichung (5.3) mit dem tatsächlichen Label vergleichen. Werden alle Datenpunkte von unserem Modell korrekt klassifiziert, so ist und damit minimal. Für den Fall, dass ein (oder mehrere) Datenpunkt(e) falsch klassifiziert wurde(n), gibt es zwei Mögkichkeiten:
-
und .
-
und .
In beiden Fällen ist die Verlustfunktion größer als Null, was bedeutet, dass die Gewichte und so angepasst werden müssen, dass der Fehler minimiert wird. Mit ein wenig linearer Algebra kann zudem gezeigt werden, dass dann proportional zur Distanz des falsch klassifizierten Datenpunkts zur Entscheidungsgrenze ist. Das einfachste, aber auch effektivste Verfahren, um die Verlustfunktion zu minimieren, ist das Gradientenabstiegsverfahren, welches Sie bereits in Abschnitt (1.3) kennengelernt haben. Dazu benötigen wir den Gradienten der Verlustfunktion nach den Gewichten und dem Bias :
Im Gegensatz zu bisherigen Verfahren, verwenden wir zur Aktualisierung der Gewichte und des Bias jedoch nicht den gesamten Gradienten, sondern lediglich den Gradienten für einen einzelnen Datenpunkt. Dies wird als Stochastisches Gradientenabstiegsverfahren (engl. Stochastic Gradient Descent, SGD) bezeichnet, und kann insbesondere bei hochdimensionalen Daten die Rechenzeit erheblich reduzieren, sowie die Konvergenz beschleunigen. Das heißt, dass die Gewichte und der Bias in jedem Schritt des Gradientenabstiegs für jeden falsch klassifizierten Datenpunkt angepasst werden:
wobei die Lernrate ist, die die Schrittweite des Gradientenabstiegs bestimmt. Hat das Modell alle Datenpunkte einmal durchlaufen, so nennen wir dies eine Epoche. Dieser Algorithmus, der auch als Rosenblatt Perzeptron bekannt ist, wird dann für eine festgelegte Anzahl von Epochen durchgeführt, oder bis alle Datenpunkte korrekt klassifiziert wurden. Für Daten, die durch eine Gerade bzw. eine (Hyper-)Ebene linear separierbar sind, kann bewiesen werden, dass der Algorithmus konvergiert und eine Entscheidungsgrenze findet, die die Daten korrekt klassifiziert.
Objektorientierte Programmierung
Bevor wir uns der Implementierung zuwenden, möchten wir eine Art des Programmierens einführen, die es uns erlaubt, ein solches ML-Modell als ein abstraktes Objekt zu implementieren, das bestimmte Funktionaltäten besitzt. Diese Art des Programmierens nennen wir Objektorientierte Programmierung (OOP), und sie unterscheidet sich von der Art und Weise, wie wir bisher in diesem Kurs programmiert haben.
Das abstrakte Grundgerüst eines Objekts, welches den Bauplan mit den Eigenschaften und Methoden dieses Objekts definiert, wird als Klasse bezeichnet. Ein Objekt, das auf Basis dieser Klasse erstellt wird, nennen wir Instanz dieser Klasse. Die Eigenschaften eines Objekts werden als Attribute bezeichnet, und die Methoden sind Funktionen, die auf diese Attribute zugreifen und diese verändern können. Diese doch sehr abstrakten Konzepte werden durch ein einfaches Beispiel klarer:
In der Chemie ist das Atom ein gutes Beispiel für ein Objekt, welches durch eine Klasse repräsentiert werden kann. Um ein Atom zu beschreiben, benötigen wir Attribute wie die Ordnungszahl oder ein Symbol, die Masse oder auch die Ladung. Methoden könnten sein, die Masse des Atoms zu berechnen oder die Ladung zu verändern. Ein konkretes Atom, wie das Wasserstoffatom, wäre dann eine Instanz dieser Klasse.
Wir definieren die Klasse Atom in Python wie folgt:
class Atom:
def __init__(self, symbol, charge):
self.symbol = symbol
self.charge = charge
Die Methode __init__ ist ein sogenannter Konstruktor, der beim Erstellen einer Instanz der Klasse
ausgeführt wird und dabei die Attribute des Atoms initialisiert. Diese Attribute sind in unserem Fall das Symbol
und die Ladung des Atoms, welche als Argumente beim Erstellen der Instanz übergeben werden. Zusätzlich ist als
Argument self notwendig, welches auf die Instanz selbst verweist. Durch die Verwendung von self können
Methoden und Attribute, die zu einer bestimmten Instanz gehören, referenziert und manipuliert werden. Dieses
Konzept wird in den folgenden Methoden deutlicher:
def get_atomic_number(self):
atomic_numbers = {'H': 1, 'C': 6, 'N': 7, 'O': 8, 'F': 9}
return atomic_numbers[self.symbol]
def set_charge(self, charge):
self.charge = charge
def get_electron_config(self):
num_electrons = self.get_atomic_number() - self.charge
config = []
orbitals = [("1s", 2), ("2s", 2), ("2p", 6)]
for orbital, max_electrons in orbitals:
if num_electrons <= 0:
break
electrons_in_orbital = min(num_electrons, max_electrons)
config.append(f"{orbital}^{electrons_in_orbital}")
num_electrons -= electrons_in_orbital
return ' '.join(config)
Die Methode get_atomic_number soll die Ordnungszahl des Atoms zurückgeben. Auch hier geben wir self als
Argument an, um auf das Atribut symbol der Instanz zuzugreifen. Da das Dictionary atomic_numbers nicht mit
self initialisiert wird, handelt es sich nicht um ein Attribut der Instanz, sondern um eine lokale Variable der
Klasse. Zudem definieren wir die Methode set_charge, die die Ladung des Atoms charge (ein Klassenattribut) verändert und keine Ausgabe hat. Neben self benötigt diese Methode natürlich auch ein entprechendes Argument.
Wir definieren eine weitere Methode get_electron_configuration, die auf die Ordnungszahl des Atoms zugreift,
um die Elektronenkonfiguration zu bestimmen und als String auszugeben.
Zuletzt können wir mit __str__ eine Methode definieren, die ausgeführt wird, wenn die Instanz des Atoms mit
dem print-Befehl aufgerufen wird:
def __str__(self):
return f"{self.symbol} ({self.charge:+d})" if self.charge != 0 \
else f"{self.symbol} (0)"
Wir initialisieren nun eine Instanz des Kohlenstoffatoms und rufen die Methoden auf:
# Create an Atom object
atom = Atom('C', 0)
print(atom) # C (0)
print(atom.get_atomic_number()) # 6
print(atom.get_electron_config()) # 1s^2 2s^2 2p^2
atom.set_charge(1)
print(atom) # C (+1)
print(atom.get_electron_config()) # 1s^2 2s^2 2p^1
Neben den Methoden, die wir in der Klasse definiert haben, können wir die Klassenattribute auch direkt aufrufen (und ggf. verändern):
print(atom.symbol) # C
print(atom.charge) # 1
Sie werden feststellen, dass Sie in der Vergangenheit bereits mit Klassen und Instanzen in Python gearbeitet
haben, ohne es zu wissen. Sei es mit Listen (list.append(element)), Dictionaries (dict.keys()) oder
numpy-Arrays (arr.shape). Tatsächlich sind all diese Datentypen Klassen, die von Python bereitgestellt
werden.
Mit diesen Konzepten im Hinterkopf wenden wir uns nun der Implementierung des Rosenblatt-Perzeptrons zu.
Dazu ist es nützlich, sich zunächst zu überlegen, welche Eigenschaften und Methoden unser Modell besitzen soll.
Globale Parameter, die nicht vom Modell gelernt werden (sogenannte Hyperparameter), sind beispielsweise die
Lernrate oder die Anzahl der Epochen. Die Gewichte und der Bias hingegen werden
vom Modell gelernt und müssen daher zu Beginn initialisiert werden. Da die Länge des Gewichtsvektors
von der Anzahl der Features abhängt, benötigen wir zu Beginn also auch diese Information. Der Konstruktor der
Klasse Perceptron könnte also folgendermaßen aussehen:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
class Perceptron:
"""Perceptron classifier.
Parameters
------------
dim : int
Dimension of the input data.
tau : float
Learning rate (between 0.0 and 1.0)
epochs : int
Passes over the training dataset.
Attributes
-----------
w : 1d-array
Weights after fitting.
b : Scalar
Bias unit after fitting.
w_list : list
Weights in every epoch.
b_list : list
Bias units in every epoch.
errors : list
Number of misclassifications (updates) in each epoch.
"""
def __init__(self, dim=2, tau=.1, epochs=100):
self.tau = tau
self.epochs = epochs
self.w = np.random.randn(dim)
self.b = 0.0
self.w_list = [self.w.copy()] # need to copy to avoid reference
self.b_list = [self.b] # no need to copy, scalar
self.errors = []
Lassen Sie sich nicht von der ausführlichen Dokumentation abschrecken, die wir hier für die Klasse Perceptron
definiert haben. Sie werden feststellen, dass in der __init__-Methode im Grunde nur die Attribute der Klasse
initialisiert werden. Zusätzlich haben wir dabei Listen für die Gewichte und den Fehler definiert, um diese
im Verlauf des Trainings zu speichern und später zu visualisieren.
Jedes ML-Modell braucht natürlich eine Methode, welche die Modellparameter an einem gegebenen Datensatz lernt.
Diese Methode nennen wir fit, und sie folgt dem Algorithmus des Rosenblatt-Perzeptrons, den wir oben
beschrieben haben:
def fit(self, X, y):
'''Fit training data'''
N = X.shape[0]
for e in range(self.epochs):
print(f"Epoch {e + 1}/{self.epochs}")
errors = 0
for xi, yi in zip(X, y):
# yi if wrongly classified, zero if correct
update = yi if yi * self.net_input(xi) < 0.0 else 0.0
# count misclassifications
errors += 1 if update != 0.0 else 0
# update parameters
self.w += self.tau / N * update * xi
self.b += self.tau / N * update
# save parameters and errors after epoch
self.w_list.append(self.w.copy())
self.b_list.append(self.b)
self.errors.append(errors)
return self
def net_input(self, x):
"""Calculate net input"""
return np.dot(x, self.w) + self.b
Innerhalb dieser Methode können wir die Klassenattribute, wie z.B. self.weights oder self.bias, direkt
aufrufen und verändern, da wir sie im Konstruktor als solche initialisiert haben. Da wir die Skalarmultiplikation
der Gewichte mit den Features und dem Bias häufiger benötigen, haben wir diese in der Methode net_input
ausgelagert.
Die Methode predict gibt dann die Vorhersage des Modells für einen gegebenen Datenpunkt zurück:
def predict(self, x):
"""Return class label after unit step"""
return np.where(self.net_input(x) >= 0.0, 1, -1)
Damit haben wir die Klasse Perceptron vollständig definiert. Wir erstellen nun zunächst eine Instanz dieser
Klasse und trainieren das Modell dann mit den Daten der Gesichter:
# Load the data
path = './eigenfaces_pca.csv'
df = pd.read_csv(path, sep=';')
# Define data matrix and labels
X = df[["pca1", "pca2"]].to_numpy()
y = df["label"].to_numpy()
# Define hyperparameters
tau = 0.001
epochs = 10
dim = X.shape[1]
# Initialize the perceptron
classifier = Perceptron(dim=dim, tau=tau, epochs=epochs)
# Fit the perceptron
classifier.fit(X, y)
Anschließend visualisieren wir die gelernte Entscheidungsgrenze des Modells und betrachten die Anzahl der falsch klassifizierten Datenpunkte über die Epochen:
# Make plot
fig, [ax1, ax2] = plt.subplots(1, 2, figsize=(12, 6))
# Plot the data points, color-coded by the labels
ax1.scatter(X[:,0][y == 1], X[:,1][y == 1], color='blue', label='Person 5')
ax1.scatter(X[:,0][y == -1], X[:,1][y == -1], color='red', label='Person 7')
# Plot the final decision boundary as a line
x = np.linspace(-5000, 5000, 1000)
y = np.linspace(-5000, 5000, 1000)
X, Y = np.meshgrid(x, y)
Z = classifier.w[0] * X + classifier.w[1] * Y + classifier.b
ax1.contour(X, Y, Z, levels=[0], colors='black', linestyles='dashed')
# Set labels
ax1.set_xticks([])
ax1.set_yticks([])
ax1.set_xlabel('PCA 5')
ax1.set_ylabel('PCA 6')
# Plot the error rate
ax2.plot(range(1, epochs+1), classifier.errors, marker='o')
# Set labels
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Number of misclassifications')
fig.tight_layout()
plt.show()
Sie sehen, dass das Perzeptron in der Lage ist, die Datenpunkte korrekt zu klassifizieren. Bereits nach wenigen Epochen konvergiert der Algorithmus und findet eine Entscheidungsgrenze, die die Datenpunkte trennt.
Sie sehen allerdings auch, dass die Entscheidungsgrenze sehr dicht an den Datenpunkten liegt. Was würde jedoch passieren, wenn wir nun einen neuen Datenpunkt hinzufügen, der zwischen den beiden Klassen liegt? Würde das Perzeptron diesen korrekt klassifizieren?
Überlegen Sie, warum die Entschiedungsgrenze (für eine ausreichend kleine Lernrate) immer dicht an den Datenpunkten liegt, indem Sie die Verlustfunktion des Perzeptrons betrachten.
Support Vector Machine
Obwohl uns der Rosenblatt-Perzeptron eine Entscheidungsgrenze liefert, welche die Daten korrekt klassifiziert, könnte es zu falschen Vorhersagen führen, wenn wir neue Datenpunkte betrachten, die zwischen den Klassen liegen oder Ausreißer enthalten. Für eine robustere Klassifikation benötigen wir also nicht nur irgendeine, sondern die optimale Entscheidungsgrenze, die uns den größtmöglichen Spielraum zwischen den Klassen lässt. Eine solche Entscheidungsgrenze kann durch die Support Vector Machine (SVM) gefunden werden.
Wir betrachten dazu die folgende Abbildung:
Sowohl in der linken als auch in der rechten Abbildung werden die Datenpunkte korrekt klassifiziert. Die Entscheidungsgrenze in der linken Abbildung hat jedoch einen größeren Margin, d.h. Abstand zwischen der Entscheidungsgrenze und den nächstgelegenen Datenpunkten.
Es kann gezeigt werden, dass der Abstand zwischen einem Punkt und der (Hyper-)Ebene , notiert als , durch gegeben ist.
Sind die Datenpunkte separierbar, so existiert ein , sodass für alle Datenpunkte auf einer Seite der Entscheidungsgrenze und auf der anderen Seite gilt. Die Breite des Margins kann dann unter Verwendung von Gleichung (5.5) als ausgedrückt werden.
Da das Ziel der SVM darin besteht, den Margin zu maximieren, können wir gleichwertig das Quadrat der Inverse von minimieren, also
Nun addieren wir diesen Term zur Verlustfunktion des Rosenblatt-Perzeptrons und erhalten die Verlustfunktion der SVM als
Das führt zu den folgenden Gradienten der Verlustfunktion nach den Gewichten und dem Bias : und der Aktualisierung der Modellparameter im stochastischen Gradientenabstiegsverfahren:
Übung
Aufgabe 1: Lineare Regression
In der Vorlesung haben Sie eine multilineare Regression an zwei Features des Wine Quality Datensatzes durchgeführt. In dieser Aufgabe wollen wir das Thema näher betrachten.
(a) Analytische Lösung der Multilinearen Regression
In Kapitel 5.1 wurde die analytische Lösung der multilinearen Regression in Gl. (5.2) als gegeben. In Kapitel 4.5 wurde in Gl. (4.14) die Lösung aber als angegeben. Zeigen Sie, dass die beiden Lösungen äquivalent sind.
Hinweis: Sie können zeigen, indem Sie die Moore-Penrose-Bedingungen (Gl. (4.10) - (4.13)) für die Matrix überprüfen. Nutzen Sie zudem die Tatsache, dass die Matrix invertierbar ist.
(b) Multilineare Regression am Wein Quality Dataset
In der Vorlesung haben wir nur die Features alcohol und volatile acidity
für die multilineare Regression verwendet. Das war zwar gut für die
spätere Visualisierung, aber das Modell beschreibt den Datensatz nur mit
unzureichender Genauigkeit. Mit mehr Features können wir eine bessere
Vorhersage treffen. Führen Sie eine multilineare Regression an allen
Features des Wine Quality Datensatzes durch. Berechnen Sie anschließend die
mittlere quadratische Abweichung (engl. Mean Squared Error, MSE) dieser
Regression sowie der Regression an den beiden Features alcohol und
volatile acidity. Vergleichen Sie die beiden Ergebnisse.
(c) Bestimmtheitsmaß
Während der MSE uns eine quantitative Aussage über die Qualität der Regression gibt, ist die Interpretation des MSE nur anhand der Daten bzw. des Kontexts möglich. Ein alternatives Maß ist das Bestimmtheitsmaß (engl. coefficient of determination), auch als notiert, dessen Wert zwischen 0 und 1 liegt. Damit ist eine kontextunabhängige Interpretation möglich. Für die multilineare Regression ist definiert als wobei die tatsächlichen Labels, die vorhergesagten Labels und der Mittelwert der Labels sind.
Berechnen Sie das Bestimmtheitsmaß für die multilineare Regression an
allen Features des Wine Quality Datensatzes sowie für die Regression
an den beiden Features alcohol und volatile acidity. Vergleichen Sie
die beiden Ergebnisse.
Aufgabe 2: Support Vector Machines
In der Vorlesung haben wir die Support Vector Machines (SVM) als eine robustere Erweiterung des Rosenblatt-Perzeptrons kennengelernt. In dieser Aufgaben sollen Sie die SVM implementieren und anwenden.
(a) Herleitung der Gleichung für den Punkt-Hyperebenen-Abstand
Seien und gegeben und definiere die Hyperebene . Zeigen Sie, dass der Abstand zwischen einem Punkt und der Hyperebene , definiert als also als der minimale Abstand zwischen dem Punkt und einem Punkt auf der Hyperebene, gilt
Tipp: Zeigen Sie, dass den Abstand realisiert, d.h. und für alle . Die erste Aussage folgt leicht aus der Definition von . Für die zweite Aussage können Sie die binomische Formel verwenden.
(b) Implementierung der SVM
Implementieren Sie die SVM durch die Klasse SupportVectorMachine anhand der
Verlustfunktion in Gl. (5.6) sowie die Update-Regel danach.
Entnehmen Sie den Konstruktor aus den folgenden Code-Block:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
class SupportVectorMachine(object):
"""Support Vector Machine classifier.
Parameters
------------
dim : int
Dimension of the input data.
tau : float
Learning rate (between 0.0 and 1.0)
lam : float
Weight for maximising the margin.
epochs : int
Passes over the training dataset.
Attributes
-----------
w : 1d-array
Weights after fitting.
b : Scalar
Bias unit after fitting.
w_list : list
Weights in every epoch.
b_list : list
Bias units in every epoch.
errors : list
Number of misclassifications (updates) in each epoch.
margins : list
Width of the margin in each epoch.
"""
def __init__(self, dim=2, tau=0.1, lam=1.0, epochs=100):
self.tau = tau
self.lam = lam
self.epochs = epochs
self.w = np.random.randn(dim)
self.b = 0.0
self.w_list = [self.w.copy()] # need to copy to avoid reference
self.b_list = [self.b] # no need to copy, scalar
self.errors = []
self.losses = []
self.margins = []
Diese Klasse enthält einige zusätzliche Argumente und Attribute im Vergleich
zur Klasse Perceptron aus der Vorlesung. Das Argument lam ist
aus Gl. (5.6). Das Attribut losses soll der Wert der
Verlustfunktion für jede Epochen speichern, während das Attribut margins
einen Maß für den Abstand der Datenpunkte zur Hyperebene, und zwar
, speichern soll.
Tipp: Sie müssen nur Kleinigkeiten der Methode fit von Perceptron
anpassen. Die Methoden net_input und predict können Sie unverändert
übernehmen.
(c) Anwendung der SVM
Trainieren Sie eine SVM mit dem gleichen Datensatz wie in der Vorlesung, also die Gesichter von zwei Personen auf zwei Hauptkomponenten. Verwenden Sie dabei und . Führen Sie das Training für 50 Epochen durch und plotten Sie die Datenpunkte mit der Entscheidungsgrenze, sowie die Verlustfunktion über die Epochen.
(d) Kernel-Trick
Bislang haben wir immer angenommen, dass die Daten linear separierbar sind. Für Daten, die näherungsweise linear separierbar sind, können wir die Verlustfunktion der SVM etwas modifizieren, sodass eine approximative Trennung möglich ist. Haben wir allerdings Daten mit stark nichtlinearen Grenzen, wie z.B. in der folgenden Abbildung, so ist die SVM in ihrer klassischen Form nicht anwendbar.
In solchen Fällen können wir den sogenannten Kernel-Trick anwenden. Dabei wird das Standardskalarprodukt durch eine symmetrische Funktion ersetzt, die als Kernfunktion (engl. kernel function) bezeichnet wird. Das führt allerdings zu einer komplexeren Optimierungsaufgabe, die wir hier nicht behandeln.
In der Tat ist dieser Ansatz aber äquivalent dazu, die Datenpunkte in einen höherdimensionalen Raum einzubetten, sodass die Datenpunkte linear separierbar sind.
Wir betrachten die folgende Einbettung: Die Ebene mit ist die Kreisgleichung in . Die neue Dimension könnte uns also helfen, die Daten in der obigen Abbildung zu separieren.
Laden Sie die Daten aus der obigen Abbildung hier herunter und verwenden Sie die oben beschriebene Einbettung . Trainieren Sie die SVM an den eingebetteten Daten mit und für 200 Epochen. Plotten Sie die Datenpunkte mit den ursprünglichen Koordinaten in sowie die Projektion der Entscheidungsgrenze in . Plotten Sie zudem die Entwicklung der Verlustfunktion über die Epochen.
Unüberwachtes Lernen
Bisher haben wir stets Daten betrachtet, die aus Datenpaaren für bestanden, wobei ein Featurevektor und ein quantitatives (Regression) oder qualitatives (Klassifikation) Label oder Target war. Beim so genannten unüberwachten Lernen hingegen fehlt diese zweite Information und wir haben lediglich Datenpunkte zur Verfügung. Dabei interessieren uns insbesondere die Dimensionsreduktion oder das Clustering der Datenpunkte.
Dimensionsreduktion
Unter Dimensionsreduktion verstehen wir die Transformation der Daten in einen Raum niedrigerer Dimension mit möglichst geringem Informationsverlust. Dazu könnten wir uns z.B. am Beispiel des Wine Quality Datensatzes fragen, ob wir zur Beschreibung der Daten anstatt allen 11 Features auch weniger verwenden können. Zudem sind manche der Features möglicherweise stark korreliert und somit überflüssig. Mit der Hauptkomponentenanalyse (PCA) haben Sie in Kapitel (4.3) bereits die wichtigste Methode zur Dimensionsreduktion kennengelernt, welche die Daten durch die Hauptkomponenten der größten Varianz beschreibt. Mit der Hauptkoordinatenanalyse (PCoA) aus Kapitel (4.4) kennen Sie zudem eine Methode zur Dimensionsreduktion, die auf Ähnlichkeiten zwischen den Datenpunkten basiert. Wir werden daher nur kurz auf die Implementierung der PCA eingehen, die wir hier als Klasse definieren wollen.
Häufig werden Methoden des überwachten und unüberwachten Lernens kombiniert oder nacheinander angewendet. So kann z.B. ein hochdimensionaler Datensatz zunächst mit PCA auf wenige Dimensionen reduziert werden, bevor ein Klassifikator darauf trainiert wird oder Clustering durchgeführt wird.
PCA Implementierung
Wir haben bereits diskutiert, dass die Hauptkomponenten der Datenmatrix durch die rechtsseitigen Singulärvektoren der Singulärwertzerlegung
gegeben sind. Unter der Annahme, dass unsere Features linear unabhängig sind, können wir ausnutzen, dass den Eigenvektoren der so genannten (empirischen) Kovarianzmatrix der (zentrierten) Datenmatrix entsprechen:
wobei Sie die letze Gleichung an die Eigenwertzerlegung erinnern sollte. Die PCA wird also durch die folgenden Schritte beschrieben:
- Normalisieren (bzw. Standardisieren) der Datenmatrix .
- Berechnen der Eigenvektoren und Eigenwerte von .
- Behalten der Eigenvektoren mit den größten Eigenwerten, gegeben als Matrix .
- Transformieren der Datenmatrix in den Raum der Hauptkomponenten durch .
Wir werden die PCA nun als Klasse implementieren, wobei wir die Berechnung der Hauptkomponenten
als Methode fit und die Transformation der Daten als Methode transform bzw. fit_transform
implementieren wollen. In der __init__ Methode initialisieren wir zunächst die Dimension
der Projektion:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
class PCA:
def __init__(self, n_components=2):
self.n_components = n_components
self.components = None
self.explained_variance = None
Dann bestimmen wir die Hauptkomponenten durch Eigenwertzerlegung der Kovarianzmatrix:
def fit(self, X):
# Center the data
X_centered = X - np.mean(X, axis=0)
# Compute the covariance matrix
cov_matrix = np.cov(X_centered, rowvar=False)
# Compute the eigenvalues and eigenvectors
eigenvalues, eigenvectors = np.linalg.eigh(cov_matrix)
# Sort the eigenvalues and corresponding eigenvectors in descending order
sorted_indices = np.argsort(eigenvalues)[::-1]
eigenvalues = eigenvalues[sorted_indices]
eigenvectors = eigenvectors[:, sorted_indices]
# Store the top n_components eigenvectors (principal components)
self.components = eigenvectors[:, :self.n_components]
# Calculate the explained variances
self.explained_variance = eigenvalues[:self.n_components] / np.sum(eigenvalues)
Anschließend können wir die Daten transformieren. Da wir die Berechnung der Hauptkomponenten
und die Transformation der Daten voneinander unabängig halten wollen, implementieren wir
diese als separate Methoden. In fit_transform werden dann beide Methoden aufgerufen:
def transform(self, X):
# Project the data onto the principal components
X_centered = X - np.mean(X, axis=0)
return np.dot(X_centered, self.components)
def fit_transform(self, X):
# Fit the model and return the transformed data
self.fit(X)
return self.transform(X)
Wir wenden die PCA auf den Iris Datensatz an, der Informationen über die Länge und Breite von Kelch- und Blütenblättern von drei verschiedenen Schwertlilien-Arten (also Blumen) enthält:
# Import Iris dataset
csv_url = 'https://archive.ics.uci.edu/ml/machine-learning-databases/iris/iris.data'
col_names = ['Sepal_Length', 'Sepal_Width', 'Petal_Length', 'Petal_Width', 'Class']
df = pd.read_csv(csv_url, names=col_names)
# Show the first few rows
print(df.head())
# Convert class labels to integers
df['Class'] = df['Class'].astype('category').cat.codes
# Define data matrix and labels
X = df.drop('Class', axis=1).to_numpy()
y = df['Class'].to_numpy()
# Perform PCA
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X)
# Plot projected data and color-code by class
fig, ax = plt.subplots(figsize=(7, 5))
ax.scatter(X_pca[y == 0, 0], X_pca[y == 0, 1], color='blue', label='Iris-setosa')
ax.scatter(X_pca[y == 1, 0], X_pca[y == 1, 1], color='red', label='Iris-versicolor')
ax.scatter(X_pca[y == 2, 0], X_pca[y == 2, 1], color='green', label='Iris-virginica')
ax.set_xlabel('Principal Component 1')
ax.set_ylabel('Principal Component 2')
ax.legend()
fig.tight_layout()
plt.show()
Dabei erhalten wir die folgende Abbildung:
Clustering
Unter Clustering verstehen wir die Gruppierung von Datenpunkten in Cluster, wobei die Datenpunkte innerhalb eines Clusters möglichst ähnlich und zwischen den Clustern möglichst verschieden sein sollen. Dazu betrachten wir im Folgenden den so genannten k-Means Algorithmus, der auch als Lloyd’s Algorithmus bekannt ist.
-Means Clustering
Gegeben seien wie zuvor Datenpunkte , welche wir in eine vorgegebene Anzahl von Clustern gruppieren möchten. Wir beginnen zunächst mit einer Wunschliste der Eigenschaften, die wir von einem Clustering-Algorithmus erwarten:
- Eine allgemeine Zuweisungsregel, die jedem Datenpunkt einen Cluster zuordnet, d.h. für .
- Eine Rekonstruktionsregel, die für jedes Cluster ein repräsentatives Element bestimmt, d.h. für .
Dabei bezeichnen wir auch als Mittelwert des Clusters . Um den -Means Algorithmus zu formulieren, fixieren wir zunächst die Anzahl der Cluster und definieren zwei Größen, und . Die Cluster-variable enthält die Teilmengen der Datenpunkte, die dem Cluster zugeordnet sind, während die Mittelwerte der Cluster enthält. Die Vereinigung der Cluster muss dabei die gesamte Datenmenge ergeben, d.h. und für , d.h. ein Datenpunkt kann nicht gleichzeitig mehreren Clustern zugeordnet sein. Der -Means Algorithmus ist ein iteratives Verfahren, welches die Cluster-Variable und die Mittelwerte abwechselnd akualisiert. Für ein initiales Clustering wird dabei zunächst der Mittelwert jedes Clusters als der Mittelwert der Datenpunkte in diesem Cluster berechnet:
wobei die Anzahl der Datenpunkte im Cluster bezeichnet. Dies entspricht der Rekonstruktionsregel. Anschließend werden die berechneten Mittelwerte festgehalten und die -te Gruppe als diejenige Menge von Datenpunkten definiert, die dem Mittelwert näher ist als jedem anderen Mittelwert für . Formal ausgedrückt bedeutet das:
was der Zuweisungsregel entspricht. Diese beiden Schritte werden dann iterativ für eine vorgegebene Anzahl von Iterationen wiederholt, oder bis sich die Cluster nicht mehr ändern. Für Daten in ist in der folgenden Abbildung ein Beispiel für den -Means Algorithmus dargestellt:
Eine andere Blickweise auf die durch den -Means Algorithmus zugeteilten Cluster stellen übrigens die so genannten Voronoi-Zellen dar, die als
definiert sind und in Voronoi-Diagrammen dargestellt werden können:
Wir implementieren auch den -Means Algorithmus als Klasse. In der __init__ Methode
initialisieren wir die Anzahl der Cluster und die maximale Anzahl an Iterationen. Zudem
setzen wir die Variablen self.centroids und self.labels, die im Laufe des Algorithmus
abwechselnd aktualisiert werden:
class KMeans:
def __init__(self, n_clusters=3, num_iter=300):
self.n_clusters = n_clusters
self.num_iter = num_iter
self.centroids = None # array of shape (n_clusters, n_features)
self.labels = None # array of shape (n_points)
Dann implementieren wir die Methode fit, die den Algorithmus wie oben beschrieben
ausführt. Nachdem wir zufällig ausgewählte Datenpunkte als Mittelwerte self.centroids
der Cluster initialisiert haben, berechnen wir in einer Schleife die Zuweisungen und Mittelwerte
der Cluster:
def fit(self, X):
# Randomly initialize centroids
random_indices = np.random.choice(X.shape[0], self.n_clusters, replace=False)
self.centroids = X[random_indices]
for i in range(self.num_iter):
# Assign labels based on closest centroid
self.labels = self.assign_labels(X)
# Calculate new centroids from the means of the points
self.centroids = self.compute_centroids(X)
Hier haben wir angenommen, dass wir die Methoden assign_labels und compute_centroids
noch implementieren werden. Dabei sei noch einmal darauf hingewiesen, dass wir auf
die Variablen self.centroids und self.labels innerhalb der Methoden der Klasse zugreifen können,
da diese als Klassenattribute definiert sind. Die Methode assign_labels berechnet zunächst die
Distanzen aller Datenpunkte zu allen Mittelwerten. Dazu erweitern wir die Datenmatrix X um eine
zusätzliche Dimension, also X.shape = (n_points, 1, n_features), um die Abstandsvektoren zu den
Mittelwerten self.centroids, die die Form (n_clusters, n_features) haben, zu berechnen. Die
Subtraktion der beiden Arrays führt also zu einem Array der Form (n_points, n_clusters, n_features).
Die Distanz erhalten wir dann durch die Berechnung der euklidischen Norm entlang der letzten Achse
(axis=2). Der Array distances speichert also für alle Datenpunkte die Distanzen zu den
Mittelwerten. Die Zuweisung erfolgt demnach durch die Auswahl des Clusters mit dem kleinsten Abstand
für jeden Datenpunkt, was mit der numpy Funktion argmin realisiert werden kann:
def assign_labels(self, X):
# Calculate the distance between each point and each centroid
distances = np.linalg.norm(X[:, None, :] - self.centroids, axis=2)
# Assign the nearest centroid to each point
return np.argmin(distances, axis=1)
Die Berechnung der Mittelwerte ist vergleichsweise einfach, da wir ledigleich für jedes Cluster die Mittelwerte der Datenpunkte des -ten Clusters berechnen müssen und in einem Array speichern müssen. Dazu nutzen wir List-Comprehension:
def compute_centroids(self, X):
# Calculate new centroids as the mean of all points assigned to each centroid
return np.array([np.mean(X[self.labels == i], axis=0) for i in range (self.n_clusters)])
def predict(self, X):
# Assign labels to new data points based on the current centroids
return self.assign_labels(X)
Um dem Konzept der allgemeinen ML-Klasse treu zu bleiben, implementieren wir auch die Methode
predict, die die Zuweisungen für ggf. neue Datenpunkte berechnet.
Wir testen unsere Implementierung des -Means Algorithmus anhand der Projektion des Iris Datensatzes auf die zwei Hauptkomponenten, die wir zuvor mit der PCA berechnet haben:
# Define hyperparameters
n_clusters = 3
num_iter = 100
# Perform K-means clustering
kmeans = KMeans(n_clusters=n_clusters, num_iter=num_iter)
kmeans.fit(X_pca)
# Extract the predicted labels
y_pred = kmeans.predict(X_pca)
# Plot the data points, color-coded by the predicted labels
fig, ax = plt.subplots(figsize=(7, 5))
ax.scatter(X_pca[y_pred == 0, 0], X_pca[y_pred == 0, 1], color='blue')
ax.scatter(X_pca[y_pred == 1, 0], X_pca[y_pred == 1, 1], color='red')
ax.scatter(X_pca[y_pred == 2, 0], X_pca[y_pred == 2, 1], color='green')
ax.set_xlabel('Principal Component 1')
ax.set_ylabel('Principal Component 2')
fig.tight_layout()
plt.show()
Dabei erhalten wir die folgende Abbildung, wobei wir die vorhergesagten Cluster durch die Farben der Punkte darstellen:
Ohne Beachtung der korrekten Farben erkennen wir durch Vergleich der tatsächlichen Labels von oben, dass die drei Cluster mit hinreichender Genauigkeit den korrekten Schwertlilien-Arten zugeordnet werden konnten. Dabei sei nochmal angemerkt, dass es sich bei Clustering um eine Methode des unüberwachten Lernens handelt, d.h. wir haben keine Information über die tatsächlichen Labels der Datenpunkte verwendet.
Übung
Aufgabe 3: -Means Clustering
In Analogie zu einer Verlustfunktion im überwachten Lernen, kann für den -Means-Algorithmus gezeigt werden, dass er die so genannte Cluster-Energie
minimiert.
(a) -Means mit variabler Anzahl an Clustern
Nutzen Sie diese Cluster-Energie, um den -Means-Algorithmus zu modifizieren, sodass die Anzahl der Cluster während des Trainings angepasst wird. Dazu können Sie z.B. nach jeder Iteration zufällige Cluster aufteilen oder zusammenführen, und diese neue Zuweisung der Datenpunkte akzeptieren, wenn die Cluster-Energie reduziert wird. Was beobachten Sie?
(b) Optimale Anzahl an Clustern
Überlegen Sie, für welche Anzahl an Clustern die Cluster-Energie minimal wird. Wie nennt man den dabei auftretenden Effekt und wie kann man ihn verhindern?
Neuronale Netzwerke
In diesem letzten Kapitel werden wir uns mit neuronalen Netzwerken beschäftigen, einer speziellen Klasse von Algorithmen des maschinellen Lernens (ML), die in den letzten Jahren besonders populär geworden sind. Dieses Teilgebiet des maschinellen Lernens wird auch als Deep Learning bezeichnet, also als ML mit tiefen neuronalen Netzen, wobei die Zusammenhänge zwischen Künstlicher Intelligenz (AI), ML und Deep Learning in der Abbildung unten dargestellt sind. Deep Learning ist zum allergrößten Teil verantwortlich für den aktuellen Hype um AI und hat in vielen Anwendungen zu großen Fortschritten geführt (z.B. ChatGPT in der Sprachverarbeitung, StableDiffusion in der Bildgenerierung und AlphaFold in der Proteinstrukturvorhersage). Die Grundidee von neuronalen Netzwerken wurde bereits in den 50er Jahren entwickelt, ist aber erst in den letzten Jahren durch Fortschritte in der Hardware, Software und Datenverfügbarkeit populär geworden. Bevor wir uns jedoch den Details von neuronalen Netzwerken zuwenden, wollen eine kurze Motivation geben, warum wir uns überhaupt mit diesem Thema beschäftigen sollten.
Wir haben in den vorherigen Abschnitten bereits einige ML-Methoden kennengelernt und angewendet. Dazu gehören Methoden des überwachten Lernes, wie z.B. die lineare Regression, und des unüberwachten Lernens, wie z.B. PCA. Was all diese Methoden gemeinsam haben, ist, dass eine Reihe an Schritten durchgeführt werden müssen, bevor ein Modell für die Vorhersage genutzt werden kann. Diese umfassen u.a.
- die Verarbeitung der Daten (Data Preprocessing),
- die Aufarbeitung von fehlenden Daten (Data Annotation),
- das Extrahieren geeigneter Features (Feature Extraction),
- oder sogar die Generierung neuer Features (Feature Engineering),
- die Auswahl eines geeigneten Modells (Model Selection),
- das Training des Modells (Model Training),
- die Evaluation des Modells (Model Evaluation),
- die Analyse der Ergebnisse (Error Analysis),
- bis hin zur Anwendung des Modells auf neue Daten (Deployment).
Viele dieser Schritte haben wir in der Vergangenheit implizit durchgeführt ohne es vielleicht zu merken, oder waren nicht notwenig, da die Daten bereits in einem geeigneten Format vorlagen. In der Praxis ist dies jedoch in den seltensten Fällen der Fall und viel Arbeit geht in die Vorbereitung der Daten. Die korrekte manuelle Auswahl geeigneter Darstellungen ist jedoch nicht immer eindeutig (z.B. bei Bildern, Texten oder Molekülen) oder geht mit erheblichen Informationsverlust einher (z.B. bei PCA).
Neuronale Netzwerke sind eine Klasse von Algorithmen, die es ermöglichen, viele dieser Schritte zu automatisieren. Sie sind in der Lage, selbstständig geeignete Darstellungen aus den Daten zu lernen, die für die Vorhersage notwendig sind. Dieser Prozess wird als Feature Learning oder Representation Learning bezeichnet und ist ein wesentlicher Vorteil gegenüber anderen ML-Methoden. Ein Nachteil ist jedoch, dass diese selbstständig gelernten Darstellungen nicht immer einfach zu interpretieren sind.
Single-Layer-Perzeptron
Wir betrachten zunächst das einfachste neuronale Netzwerk, das sogenannte Single-Layer-Perzeptron (SLP). Den Begriff des Perzeptrons kennen Sie bereits aus dem vorherigen Abschnitt über binäre Klassifikation und bezeichnet ein Modell, das einen beliebigen Eingabevektor auf eine binäre Ausgabe abbildet.
Um die Idee eines neuronalen Netzwerks zu verstehen, bzw. eines einzelnen künstlichen Neurons, müssen wir jedoch zunächst zurück auf die (Sie haben es wahrscheinlich schon befürchtet) lineare Regression blicken, genauer gesagt auf die Klassifikation mittels linearer Regression. Wir hatten bereits gesehen, dass sich die lineare Regression für die binäre Klassifikation aufgrund von zwei Problemen nicht eignet:
- Die Ausgabe der linearen Regression ist kontinuierlich, d.h. sie kann beliebige Wert zwischen und annehmen, während wir für die binäre Klassifikation nur zwei Werte benötigen (z.B. 0 und 1).
- In der Klassifikation sind die Labels in der Regel nicht geordnet, d.h. es gibt keine natürliche Ordnung zwischen den Klassen.
Wohingegen wir das erste Problem dadurch lösen konnten, dass wir die Ausgabe denjenigen Labels der Klassen zugewiesen haben, die der Ausgabe am nächsten liegen, zeigt das zweite Problem, dass Klassifikation und Regression grundlegend verschieden sind. Nach Vertauschen der Labels würde sich bei nur zwei Klassen das Vorzeichen der Ausgabe ändern, was wir durch Umkehrung der Entschiedungsgrenze kompensieren könnten. Bei mehr als zwei Klassen würde eine Vertauschung der Labels jedoch zu einer Änderung der Ausgleichsgeraden führen, was zeigt, dass wir die lineare Regression nicht für die Klassifikation mit mehr als zwei Klassen verwenden können.
Logistische Regression
Wir nehmen an, dass und für . Die Klassenlabels sind also entweder 0 oder 1, d.h. wir haben eine binäre Klassifikation. Anstatt nun wie zuvor, die kontinuierliche Vorhersage des Models eindeutig einem Label zuzuweisen, können wir die Ausgabe als eine Wahrscheinlichkeit interpretieren, dass das vorhergesagte Label des Datenpunkt gleich 1 ist. Die Wahrscheinlichkeit, dass gleich 0 ist, ist dann . Dazu muss aber die Ausgabe des Modells so transformiert werden, sodass sie im Intervall liegt. Eine Möglichkeit ist die Verwendung der logistischen Funktion
die auch als Sigmoid-Funktion bekannt ist und die reellen Zahlen auf das Intervall abbildet:
Unser Model lautet dann
was als logistische Regression (engl. logistic regression) bezeichnet wird. Beachten Sie, dass trotz des Namens die logistische Regression ein Klassifikationsmodell ist.
Wie finden wir nun die optimalen Parameter und , sodass die Vorhersage des Modells für alle Datenpunkte möglichst gut mit den tatsächlichen Labels übereinstimmt? Dazu benötigen wir eine Objektivfunktion, die uns sagt, wie gut unser Modell die Daten beschreibt. Die Interpretation der Ausgabe als Wahscheinlichkeit legt nahe, dass wir versuchen sollten, die Wahrscheinlichkeit zu maximieren (bzw. ). Über alle Datenpunkte können wir also das Produkt der Wahrscheinlichkeiten
maximieren, welches als die sogennante Likelihood bezeichnet wird. gibt also an, wie wahrscheinlich es ist, die tatsächlichen Datenpunkte durch unser Modell zu erhalten, welches durch die Parameter beschrieben wird. Der Ansatz, die Likelihood zu maximieren, wird als Maximum-Likelihood-Estimation (MLE) bezeichnet und ist eines der wichtigsten Prinzipien in der Statistik.
Da unsere Labels entweder 0 oder 1 sind, können wir die Likelihood auch als Produkt der Wahrscheinlichkeiten schreiben, dass für die Datenpunkte mit und für die Datenpunkte mit ist:
Diese Funktion zu maximieren bedeutet, dass wir die Ableitung nach den Parametern berechnen müssen. Da die Likelihood ein Produkt ist, ist es einfacher, den Logarithmus der Likelihood zu maximieren, da der Logarithmus eines Produkts die Summe der Logarithmen der Faktoren ist. Um zudem etwas analoges zu einer Verlustfunktion zu erhalten, minimieren wir stattdessen den negativen Logarithmus der Likelihood:
Sie können sich als Übungsaufgabe davon überzeugen, dass die Gradienten der Verlustfunktion nach den Parametern und gegeben sind durch
Da leider keine geschlossene Lösung für die Parameter existiert, welche die Gleichung löst, wenden wir wieder das stochastische Gradientenverfahren an, um die Parameter iterativ zu optimieren.
Implementierung der logistischen Regression
Die Implementierung der logistischen Regression ist einfach umzusetzen, da sie sich nur geringfügig von den bisherigen Modellen unterscheidet.
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
# Define model class
class Logistic_Regression:
def __init__(self, dim=2, tau=0.1, epochs=100):
self.tau = tau
self.epochs = epochs
self.weights = np.random.rand(dim)
self.bias = 0
def sigmoid(self, x):
return 1 / (1 + np.exp(-x))
def fit(self, X, y):
N = X.shape[0]
for e in range(self.epochs):
print(f"Epoch {e + 1}/{self.epochs}")
for xi, yi in zip(X, y):
self.weights += self.tau / N * (yi - self.sigmoid(self.net_output(xi))) * xi
self.bias += self.tau / N * (yi - self.sigmoid(self.net_output(xi)))
def net_output(self, x):
return np.dot(x, self.weights) + self.bias
def predict(self, x):
return np.where(self.net_output >= 0, 1, 0)
Wir wenden die logistische Regression auf die Daten der Eigenfaces an, welche wir bereits für das Rosenblatt-Perzeptron verwendet haben. Um die Vorhersage des Modells anschaulich darzustellen, verwenden wir allerdings nur eine Dimension der Daten:
# Load the data
path = './eigenfaces_pca.csv'
df = pd.read_csv(path, sep=';')
# Define data matrix and labels
X = df[["pca2"]].to_numpy() # take only one feature for simplicity
y = df["label"].to_numpy()
# Normalize data
X = (X - X.mean()) / X.std()
y[y == -1] = 0 # change -1 to 0
# Define hyperparameters
dim = X.shape[1]
tau = 0.1
epochs = 100
# Instantiate the model
LR = Logistic_Regression(dim=dim, tau=tau, epochs=epochs)
# Fit the model
LR.fit(X, y)
# Make predictions
x_grid = np.linspace(-3, 3, 100)
decision_line = np.array([LR.net_output([x]) for x in x_grid])
# Perform simple linear regression for comparison
beta_1, beta_0 = np.polyfit(X.flatten(), y, 1)
# Plot the data and decision line
fig, ax = plt.subplots(figsize=(6, 5))
ax.plot(X[y == 0], y[y == 0], 'o', color='b', label='Data')
ax.plot(X[y == 1], y[y == 1], 'o', color='r', label='Data')
ax.plot(x_grid, beta_1 * x_grid + beta_0, 'grey', linestyle='--', label='Linear regression')
ax.plot(x_grid, decision_line, 'g', label=r'$w x + b$')
ax.plot(x_grid, LR.sigmoid(decision_line), 'm', label=f'$\sigma(w x + b)$')
ax.set_ylim(-0.1, 1.1)
ax.set_xticklabels([])
ax.set_xlabel('PCA2')
plt.legend()
fig.tight_layout()
plt.show()
Wir erhalten den folgenden Plot, wobei wir als Referenz auch die naive lineare Regression hinzugefügt haben:
Für die logistische Regression anhand der zwei Dimensionen, in welcher die Daten linear separierbar sind, erhalten wir übrigens die folgende Abbildung. Das Modell klassifiziert die Daten im Gegensaatz zur linearen Regression korrekt:
Es ist dabei anzumerken, dass die sigmoide Vorhersage des Modells, also die Wahrscheinlichkeit , für linear separierbare Datenpunkte gegen die sogenannte Heaviside-Funktion konvergiert
die eine einfache Stufenfunktion dastellt.
Auch wenn die logistische Regression deutlich besserere Ergebnisse liefert als die lineare Regression, müssen wir zur entgültigen Klassifizierung von neuen Datenpunkten eine Entscheidungsgrenze festlegen, z.B. . Damit haben wir jedoch wieder das Problem, dass wir nur lineare Entscheidungsgrenzen erhalten, die nicht immer die beste Trennung der Datenpunkte ermöglichen. Dies wird anhand des sogennanten XOR-Problems deutlich, was Ende der 60er Jahre zu einem Stillstand in der Entwicklung von neuronalen Netzwerken geführt hat.
Um zu verstehen, wie wir das Modell (6.1) erweitern können, betrachten wir zunächst die Vorhersage der logistischen Regression in einem sogenannten Rechengraphen (engl. computational graph):
Dabei repräsentieren die Knoten die Eingabedaten, welche entlang der Kanten mit den Gewichten multipliziert werden. Die Knoten und stellen dann die Rechenoperation der Summation und der Sigmoid-Aktivierungsfunktion der eingehehenden Daten dar, wobei wir die Addition des Bias implizit angenommen haben. Basierend auf der Ausgabe kann dann das Label oder Target bestimmt werden. In Anlehnung an die biologischen Neuronen, bezeichnet man diese Rechenoperation als künstliches Neuron. Damit stellt die logistische Regression bereits ein einfaches künstliches neuronales Netzwerk dar, das aus einem einzigen Neuron besteht.
Neuronen, oder auch Nervenzellen, sind die Grundbausteine des Nervensystems und bilden die Grundlage für die Informationsverarbeitung im Gehirn. Ein Neuron ist im wesentlichen eine elektrisch erregbare Zelle, die in der folgenden Abbildung schematisch dargestellt ist:
Sie besteht aus einem Zellkörper, den Dendriten, die die eingehenden Signale empfangen, und dem Axon, das die Signale weiterleitet. Durch Akkumulieren der Signale an den Dendriten wird ein elektrisches Potential aufgebaut. Wenn das Potential zusammen mit dem eigenen Restpotential einen Schwellwert überschreitet, wird ein elektrischer Impuls, das sogenannte Aktionspotential, entlang des Axons weitergeleitet. Am Ende des Axons wird der Impuls auf andere Neuronen übertragen, wobei die Synapsen die Verbindungen zwischen den Neuronen darstellen. Dieses Prinizip wird häufig als Alles-oder-Nichts-Prinzip bezeichnet. Man sagt auch, dass ein Neuron entweder feuert oder nicht.
Sie können nun selbst die Parallelen zwischen den biologischen Neuronen und den künstlichen Neuronen ziehen oder auch die Unterschiede erkennen.
Single-Layer-Perzeptron
Wir können das Modell der logistischen Regression nun erweitern, indem wir mehrere künstliche Neuronen miteinander verknüpfen. Die Eingabe wird dabei zunächst an zwei oder mehrere Neuronen weitergeleitet, die jeweils eine eigene Gewichtung und Aktivierungsfunktion besitzen. Die Ausgabe der Neuronen wird dann (in Analogie zu den biologischen Neuronen) an ein weiteres Neuron weitergeleitet, welches diese Signale erneut gewichtet und aufsummiert. Ggf. kann diese gewichtete Summe noch durch einen Bias ergänzt und durch eine weitere Aktivierungsfunktion modifiziert werden. Dieses Modell wird als Single-Layer-Perzeptron (SLP) bezeichnet, da es nur eine eine einzelne Schicht an versteckten Neuronen besitzt.
Mit der Notation , , und kann die zugrundeliegende Rechenoperation des SLPs als
dargestellt werden, wobei , und lernbare Parameter sind, und die Aktivierungsfunktion der Neuronen darstellt. Unter Verwendung der Matrix und der Vektoren und kann dies auch als Skalarprodukt
geschrieben werden. Der detaillierte Rechengraph des SLP ist in der folgenden Abbildung dargestellt:
Auch wenn wir auf den ersten Blick nur minimale Änderungen vorgenommen haben, erlaubt uns das Einführen einer einzelnen versteckten Schicht (engl. hidden layer) bereits nichtlineare Entscheidungsgrenzen zu modellieren. Tatsächlich können wir mit dem SLP, sofern wir eine nichtlineare Aktivierungsfunktion wählen, jede beliebige Funktion approximieren, was als das universelle Approximationstheorem bezeichnet wird.
Das universelle Approximationstheorem besagt, dass ein neuronales Netzwerk mit mindestens einer versteckten Schicht und einer nichtlinearen Aktivierungsfunktion jede beliebige Funktion approximieren kann, sofern genügend Neuronen in der versteckten Schicht vorhanden sind. Mathematisch ausgedrückt bedeutet dies, dass für jede stetige Funktion und für jedes ein neuronales Netzwerk existiert, sodass der Fehler für alle gilt.
Der Beweis des Theorems ist simpel, sofern man einige Kenntnisse der Funktionalanalysis besitzt. Da dies jedoch den Rahmen dieses Kurses sprengen würde, bieten wir im Folgenden eine interaktive und stark vereinfachte Visualisierung des Theorems an. Dabei beschränken wir uns auf die Approximation einer einfachen Funktion durch ein SLP mit einer versteckten Schicht, die aus 2 Neuronen besteht. Mit den Slidern können Sie die Parameter , und der Neuronen verändern und die beste Approximation der Funktion durch das SLP finden. Wir möchten Sie dazu ermuntern, auch die Zielfunktion zu ändern oder die Anzahl der Neuronen zu erhöhen, um die Approximation zu verbessern.
import numpy as np
import matplotlib.pyplot as plt
from ipywidgets import interact, FloatSlider
#%matplotlib inline
# Define the sigmoid activation function
def sigmoid(x):
return 1 / (1 + np.exp(-x))
# Initialize weights and biases
w = np.random.randn(2)
b = np.random.randn(2)
a = np.random.randn(2)
# Define the neural network forward pass
def neural_network(x, w, b, a):
h = np.dot(w, x) + b
return np.dot(a, sigmoid(h))
# Plotting function with sliders
def plot_network(w0, w1, b0, b1, a0, a1):
# Update weights and biases based on slider values
w = np.array([w0, w1])
b = np.array([b0, b1])
a = np.array([a0, a1])
# Generate input values and compute network output
x_values = np.linspace(-10, 10, 400)
y_values = [neural_network(x, w, b, a) for x in x_values]
# Target function
f = lambda x: np.exp(-x**2)
# Plotting
fig, ax = plt.subplots(figsize=(8, 6))
ax.plot(x_values, y_values, label='SLP output')
ax.plot(x_values, f(x_values), label='Target function')
ax.set_xlabel('Input')
ax.set_ylabel('Output')
ax.legend()
plt.show()
# Create sliders for weights and biases
interact(plot_network,
w0=FloatSlider(value=w[0], min=-5, max=5, step=0.1),
w1=FloatSlider(value=w[1], min=-5, max=5, step=0.1),
b0=FloatSlider(value=b[0], min=-5, max=5, step=0.1),
b1=FloatSlider(value=b[1], min=-5, max=5, step=0.1),
a0=FloatSlider(value=a[0], min=-5, max=5, step=0.1),
a1=FloatSlider(value=a[1], min=-5, max=5, step=0.1))
Damit das SLP-Modell, also das neuronale Netzwerk mit einer versteckten Schicht, die Zielfunktion approximieren kann, müssen wir die Parameter , und so anpassen, dass der Fehler zwischen der Vorhersage und dem tatsächlichen Wert minimiert wird. Dies ist das normale Vorgehen im überwachten Lernen, wobei wir die Verlustfunktion
minimieren wollen. Dazu müssen wir die Gradienten der Verlustfunktion nach den Parametern , und berechnen, um diese mit Hilfe des Gradientenverfahrens zu optimieren. Unter Verwendung der Kettenregel, der Definition des SLPs in Gl. (6.3) sowie der Hilfsvariable können die Gradienten wie folgt berechnet werden:
Dabei bezeichnet das elementweise Produkt der Vektoren und die Ableitung der Aktivierungsfunktion (hier der Sigmoid-Funktion). Beachten Sie auch, dass wir bei der Berechnung der Gradienten die Transponierung der Matrizen und Vektoren berücksichtigen müssen, um die Dimensionen korrekt zu erhalten.
Neben dem ursprünglichen Gradientenverfahren, welches den Gradienten der gesamten Datenpunkte nutzt, kennen wir bereits das stochastische Gradientenverfahren. Dabei wir der Gradient nur für einen (zufällig) ausgewählten Datenpunkt berechnet und die Parameter anschließend angepasst, was insbesondere bei sehr großen Datensätzen effizienter ist. Eine weitere sehr effiziente Methode ist das sogenannte Mini-Batch-Gradientenverfahren, was sozusagen eine Mischung aus den beiden Verfahren darstellt. Dabei wird der Gradient für eine kleine, zufällig ausgewählte Teilmenge an Datenpunkten berechnet, der sogenannte Batch, und die Parameter entsprechend angepasst. Die Größe der Teilmenge (Batch-Size) kann dabei variabel gewählt und anhand der Verfügbaren Rechenressourcen angepasst werden.
Die Aktualisierung der Parameter erfolgt dann wie folgt:
wobei die Lernrate darstellt und die Verlustfunktion für einen Datenpunkt ist.
Implementierung des Single-Layer-Perzeptrons
Wir implementieren zunächst die Aktivierungsfunktion und deren Ableitung, die wir für die Berechnung
der Gradienten benötigen. Dazu nutzen wir ebenfalls eine Klasse. Dank der __call__-Methode können wir das
Objekt dann wie eine herkömmliche Funktion verwenden.
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
# Define activation function class
class Sigmoid:
def __call__(self, x):
return 1 / (1 + np.exp(-x))
def gradient(self, x):
return self(x) * (1 - self(x))
Die __init__-Methode der Klasse SLP sollte keine Überraschungen bereithalten. Wir initialisieren
die Parameter des Modells , und zufällig und instanziieren die Sigmoid-Klasse.
Neu ist auch, dass wir die Batch-Size festlegen müssen:
# Define model class
class SLP:
def __init__(self, dim=2, hidden_size=2, activation='Sigmoid', epochs=100, tau=0.1, batch_size=5):
self.weights = np.random.randn(dim, hidden_size)
self.bias = np.random.randn(hidden_size)
self.linear_weights = np.random.randn(hidden_size)
if activation == "Sigmoid":
self.activation = Sigmoid()
else:
raise NotImplementedError(f"Activation function not implemented.")
self.epochs = epochs
self.tau = tau
self.batch_size = batch_size
self.losses = []
def feedforward(self, x):
z = np.dot(self.weights.T, x) + self.bias
return np.dot(self.linear_weights.T, self.activation(z))
Zudem haben wir die Methode feedforward gemäß Gl. (6.3)
implementiert, die die Vorhersage des Modells für einen
Datenpunkt berechnet.
Um das Mini-Batch-Gradientenverfahren durchzuführen, müssen wir die Daten zunächst in zufällige
Batches aufteilen. Dazu erstellen wir ein Array indices, der die Indizes der Datenpunkte
enthält und mischen diesen zufällig. Innerhalb der Schleife über die Batches, wobei wir
in Schritten der Batch-Size iterieren, definieren wir dann die Teilmenge der Datenpunkte
und iterieren wiederum über diese. Da wir die Gradienten für die Datenpunkte in der Teilmenge
aufsummieren, müssen wir diese zunächst als leere Arrays initialisieren.
def train(self, X, y):
N = X.shape[0]
for e in range(self.epochs):
print(f"Epoch {e + 1}/{self.epochs}")
# Shuffle data
indices = np.arange(N)
np.random.shuffle(indices)
# Iterate over batches
loss = 0
for i in range(0, N, self.batch_size):
# Define batch
batch_indices = indices[i:i + self.batch_size]
X_batch = X[batch_indices]
y_batch = y[batch_indices]
# Initialize gradients
gradient_w = np.zeros_like(self.weights)
gradient_b = np.zeros_like(self.bias)
gradient_lw = np.zeros_like(self.linear_weights)
# Accumulate gradients over the batch
for xi, yi in zip(X_batch, y_batch):
zi = np.dot(self.weights.T, xi) + self.bias
d_inner = self.linear_weights * self.activation.gradient(zi)
residue = self.feedforward(xi) - yi
loss += residue ** 2
# Compute gradients
gradient_w += residue * np.outer(d_inner, xi).T
gradient_b += residue * d_inner
gradient_lw += residue * self.activation(zi)
# Update parameters after each batch
self.weights -= self.tau / self.batch_size * gradient_w
self.bias -= self.tau / self.batch_size * gradient_b
self.linear_weights -= self.tau / self.batch_size * gradient_lw
self.losses.append(loss / N)
Die Berechnung der Gradienten erfolgt dann gemäß Gl. (6.4). Hier haben wir
uns eine Reihe an Hilfsvariablen definiert, wie z.B. d_inner, was
enspricht und die
Berechnung der Gradienten vereinfacht. Beachten Sie auch, dass wir die numpy-Funktion
np.outer verwendet haben,
um das äußere Produkt der Vektoren d_inner und x zu berechnen.
Da die Gewichte, und somit auch die Gradienten, allerdings die Dimensionen haben, müssen
wir das äußere Produkt transponieren.
Wir trainieren das SLP-Modell anhand der Daten der zwei eigebetteten Kreise, die wir bereits aus der Übung zu (Kernel)-SVMs kennen. Das Ziel ist also, die Datenpunkte in die beiden Klassen 1 und -1 zu klassifizieren, wobei wir die kontinuierliche Ausgabe des Modells nicht weiter einschränken. Dazu verwenden wir eine versteckte Schicht mit 50 Neuronen und der Sigmoid-Aktivierungsfunktion:
# Load the data
path = './circles.csv'
df = pd.read_csv(path, sep=';')
# Define data matrix and labels
X = df[['x_1', 'x_2']].to_numpy()
y = df['label'].to_numpy()
# Set hyperparameters
hidden_size = 50
tau = 0.01
dim = X.shape[1]
epochs = 100
batch_size = 12
# Instantiate the model
f_hat = SLP(dim=dim, hidden_size=hidden_size, tau=tau, epochs=epochs, batch_size=batch_size)
# Train the model
f_hat.train(X, y)
Wir visualisieren die Entscheidungsgrenze des SLPs () zusammen mit dem Loss des Modells und erhalten den folgenden Plot:
# Make plot
fig, [ax1, ax2] = plt.subplots(1, 2, figsize=(12, 6))
# Plot the data points, color-coded by the labels
ax1.scatter(X[y == 1, 0], X[y == 1, 1], color='blue', label='Class 0')
ax1.scatter(X[y == -1, 0], X[y == -1, 1], color='red', label='Class 1')
# Plot the decision boundary
x1_grid = np.linspace(-8, 8, 100)
x2_grid = np.linspace(-8, 8, 100)
X1_grid, X2_grid = np.meshgrid(x1_grid, x2_grid)
Y = np.zeros_like(X1_grid)
for i, x1 in enumerate(x1_grid):
for j, x2 in enumerate(x2_grid):
Y[i, j] = f_hat.feedforward([x1, x2])
ax1.contour(X1_grid, X2_grid, Y, levels=[0.0], colors='black', linestyles='dashed')
ax1.contourf(X1_grid, X2_grid, Y, levels=[-10.0, 0.0, 10.0], colors=['red', 'blue'], alpha=0.2)
# Plot the loss over epochs
ax2.plot(f_hat.losses)
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Loss')
fig.tight_layout()
plt.show()
Wie Sie sehen können, ist das SLP in der Lage, eine nichtlineare Entscheidungsgrenze zu modellieren, die die Datenpunkte korrekt klassifiziert.
Ändern Sie die Batch-Size, die Anzahl der Neuronen oder die Lernrate, und beobachten Sie, wie sich die Entscheidungsgrenze und der Loss des Modells verändern.
Übung
Aufgabe 1: Gradienten der logistischen Regression
Zeigen Sie, dass die Gradienten der Verlustfunktion der logistischen Regression (6.2) nach den Parametern und gegeben sind durch
Zeigen Sie dazu (unter Benutzung der Kettenregel) zunächst, dass die Ableitung der Sigmoid-Funktion gegeben ist durch
Vergleichen Sie die Gradienten der logistischen Regression mit den Gradienten der linearen Regression für die Verlustfunktion der Methode der kleinsten Quadrate. Was fällt Ihnen auf?
Aufgabe 2: Binäre Kreuzentropie
Wie wir schon in der Vorlesung gesehen haben, ist die Verlustfunktion der Methode der kleinsten Quadrate keine geeignete Methode, um Klassifikationsprobleme zu lösen. Für binäre Klassifikationsprobleme wird daher in der Regel die binäre Kreuzentropie
als Verlustfunktion verwendet. Dabei ist das wahre Label des -ten Datenpunkts, und die Vorhersage des Modells. Hier setzt die Verwendung des Logarithmus voraus, dass die Vorhersage des Modells auf das Intervall abgebildet wird, was durch die Sigmoid-Funktion erreicht werden kann.
(a)
Modifizieren Sie die Implementierung des SLP anhand des Circles-Datensatzes, sodass die binäre Kreuzentropie als Verlustfunktion verwendet wird. Beachten Sie, dass Sie dazu die Gradienten der Verlustfunktion nach den Parametern und berechnen müssen, wobei Ihnen die Ergebnisse der vorherigen Aufgabe helfen können.
(b)
Wie ist die Verlustfunktion der negativen log likelihood (6.2) der logistischen Regression mit der (binären) Kreuzentropie verwandt?
Multi-Layer-Perzeptron
Die Flexibilität und Expressivität eines Single-Layer-Perzeptrons (SLP), also eines neuronalen Netzwerks mit nur einer versteckten Schicht, ist bereits erstaunlich aber dennoch begrenzt. Zwar können wir die Dimensionen der Eingabe, der Ausgabe und der versteckten Schicht beliebig wählen, jedoch müssen wir unter Umständen eine sehr große Anzahl an Neuronen in der versteckten Schicht verwenden, um eine Funktion beliebig genau approximieren. Dies führt zu einem hohen Rechenaufwand und einer schlechten Generalisierung auf unbekannte Daten. Eine vereinfachte schematische Darstellung eines SLP ist in der Abbildung unten dargestellt. Dabei ist die Summation der gewichteten Eingaben und die Aktivierungsfunktion in der versteckten Schicht (grün) in einem einzigen Schritt zusammengefasst.
Die Verwendung des Begriffes Schicht lässt bereits vermuten, dass wir die Architektur eines neuronalen Netzwerks erweitern können, indem wir mehrere Schichten für von Neuronen hintereinander schalten. Ein solches Netzwerk wird als Multi-Layer-Perzeptron (MLP) bezeichnet und ist in der Abbildung unten dargestellt.
Ein MLP besteht aus einer Eingabeschicht, welche die Eingabedaten aufnimmt und versteckten Schichten , die jeweils aus Neuronen bestehen. Die einzelnen Schichten (layers) sind durch Gewichte und Bias miteinander verbunden. Da die Neuronen benachbarter Schichten vollständig miteinander verbunden sind, nennt man diese Architektur auch fully connected oder dense Netzwerke. In der Regel wählt man für alle versteckten Schichten die gleiche Aktivierungsfunktion, wir werden aber im Folgenden allgemeine Aktivierungsfunktionen berücksichtigen. Die letzte Schicht des MLP ist dann die Ausgabeschicht , die die Vorhersage des Modells liefert. Im Gegensatz zum SLP nehmen wir hier einen vektoriellen Output an, der z.B. die Wahrscheinlichkeiten für verschiedene Klassen in einem Klassifikationsproblem darstellen kann.
Wir fassen die Berechnung der Ausgabe eines MLP für einen Datenpunkt , was auch als forward pass bezeichnet wird, zusammen:
- Die Eingabe wird in die erste versteckte Schicht übergeben.
- Die Ausgaben der versteckten Schichten werden rekursiv aus den Ausgaben der vorherigen Schichten berechnet:
- Die Ausgabe des gesamten MLP kann demnach als Komposition der vorherigen Schichten geschrieben werden:
Was bedeutet das für das Training eines MLP? Um die optimalen Gewichte und Bias zu finden, wenden wir das stochastische, bzw. mini-batch Gradientenverfahren an. Dazu müssen wir die Gradienten
einer (allgemeinen) Verlustfunktion nach den Gewichten und Bias für alle berechnen. Aufgrund der Komposition der Schichten wenden wir dazu die Kettenregel der Ableitung mehrfach an. Da wir dazu von hinten, also bei der Ausgabeschicht, beginnen, wird dieses Verfahren auch backpropagation genannt.
Wir definieren zunächst die Aktivierung der -ten Schicht, sodass wir die Ausgabe der Schicht als schreiben können. Unter Verwendung der Kettenregel erhalten wir
Schreiben wir die Aktivierung eines einzelnen Neurons in der -ten Schicht als , so erhalten wir für die jeweils zweiten Faktoren in Gl. (6.5):
Es bleiben also die Ableitungen der Verlustfunktion nach der Aktivierung der -ten Schicht zu berechnen. Wir betrachten zunächst die letzte Schicht . Da , erhalten wir
Das Ergebnis sieht auf den ersten Blick kompliziert aus, jedoch hat es eine einfache Interpretation. Der erste Faktor, die Ableitung der Verlustfunktion nach der Ausgabe der letzten Schicht ist abhängig von der gewählten Verlustfunktion. Für die mean squared error Verlustfunktion ist dies z.B. einfach , bzw. . Auch der zweite Faktor, die Ableitung der Aktivierungsfunktion , kann einfach berechnet werden, da wir die Aktivierungsfunktion explizit gewählt haben und aus dem forward pass bekannt ist. Für Regressionsprobleme wählt man übrigens oft die Identitätsfunktion als Aktivierungsfunktion der letzten Schicht, um die Ausgabe nicht zu beschränken.
Somit lauten die Ableitungen der Verlustfunktion nach den Gewichten und Bias der letzten Schicht
Für die versteckten Schichten machen wir uns zunächst bewusst, dass die Ableitung der Verlustfunktion nach der Aktivierung der -ten Schicht von der Aktivierung der -ten Schicht abhängt. Da außerdem an alle Neuronen der -ten Schicht weitergegeben wird, folgt aus der Kettenregel
Wir erkennen, dass hier nun auftaucht, was wiederum in unserem backward pass bereits gemäß der gleichen Formel (bis ) berechnet wurde. Der zweite Faktor kann aus der Definition mit explizit berechnet werden:
Daraus folgt die zentrale Rekursionsformel für den backward pass:
Setzen wir nun diese Ergebnisse in Gl. (6.5) ein, so erhalten wir die Gradienten
Was sagen uns nun diese Gleichungen über die tatächliche Berechnung der Gradienten im Training eines MLP? Aufgrund der Rekursion müssen wir die Gradienten der letzten Schicht zuerst berechnen und können dann die Gradienten der vorherigen Schichten rekursiv aus den Gradienten der nachfolgenden Schichten erhalten. Alle weiteren Bestandteile der Berechnung, wie und , sind bereits aus dem forward pass bekannt. Das bedeutet, dass wir für die Berechnung des Gradienten eines Datenpunktes zunächst einen forward pass mit den aktuellen Gewichten und Bias durchführen und dabei die Aktivierungen und speichern.
Implementierung eines MLP
Das genaue Vorgehen sollte spätestens während der Implementierung des MLP klar werden. Wir beginnen mit der Aktivierungsfunktion, die wir bereits für das SLP verwendet haben.
import numpy as np
import matplotlib.pyplot as plt
class Sigmoid():
def __call__(self, x):
return 1 / (1 + np.exp(-x))
def gradient(self, x):
return self(x) * (1 - self(x))
Es sei angemerkt, dass es neben der Sigmoid-Aktivierungsfunktion noch viele weitere Aktivierungsfunktionen gibt, die in der Praxis verwendet werden. Recherchieren Sie dazu z.B. die Rectified Linear Unit (ReLU) oder die Hyperbolic Tangent (tanh) Funktion und implementieren Sie diese in Ihrem Code.
Anschließend implementieren wir die __init__ Methode der Klasse MLP, die die Klassenattribute setzt und die
Gewichte und Bias initialisiert. Die Gewichte und Bias der einzelnen Schichten müssen hierbei in Listen gespeichert werden, da die
Schichten unterschiedlich viele Neuronen haben können. Wir gehen hier davon aus, dass die Architektur des MLP in einem Dictionary
sizes festgelegt ist, das als Keys die Anzahl der Neuronen pro Schicht und als Values die Aktivierungsfunktion
enthält.
class MLP():
def __init__(self, sizes, tau=0.1, batch_size=5):
# Initialize the network's parameters
self.sizes = list(sizes.keys())
self.activations = list(sizes.values())
self.num_layers = len(self.sizes)
self.weights = [np.random.randn(x, y) for x, y in zip(self.sizes[:-1], self.sizes[1:])]
self.biases = [np.random.randn(y) for y in self.sizes[1:]]
self.tau = tau
self.batch_size = batch_size
def feedforward(self, xi):
# Compute the output of the network for input xi
h_l = xi
for b_l, w_l, activation_l in zip(self.biases, self.weights, self.activations[1:]):
h_l = activation_l(np.dot(w_l.T, h_l) + b_l)
return h_l
Die feedforward-Methode ist dann relativ einfach zu implementieren, wobei wir zusammen über die Gewichte, Bias und
Aktivierungsfunktionen der Schichten iterieren und die Ausgabe rekursiv berechnen.
Um das Training des MLP ein wenig zu entzerren, implementieren wir zunächst die Methode train_step, welche für
alle Daten eine Epoche das mini-batch Gradientenverfahren durchführt.
def train_step(self, X, y):
# Train the network using mini-batch gradient descent
# Shuffle the training data
N = X.shape[0]
indices = np.arange(N)
np.random.shuffle(indices)
# Iterate over mini-batches
for i in range(0, N, self.batch_size):
batch_indices = indices[i:i+self.batch_size]
self.update_mini_batch(X[batch_indices], y[batch_indices])
def update_mini_batch(self, X_batch, y_batch):
# Update the network's parameters using gradient descent
# Initialize gradients for this minibatch
gradient_weights = [np.zeros_like(w) for w in self.weights]
gradient_bias = [np.zeros_like(b) for b in self.biases]
# Iterate over all training pairs in this minibatch
for xi, yi in zip(X_batch, y_batch):
# Compute gradients for this training pair
gradient_weights_i, gradient_bias_i = self.backprop(xi, yi)
# Update the gradients for this training pair
gradient_weights = [dw + dw_i for dw, dw_i in zip(gradient_weights, gradient_weights_i)]
gradient_bias = [db + db_i for db, db_i in zip(gradient_bias, gradient_bias_i)]
# Update the network's parameters using the gradients
self.weights = [w - (self.tau / self.batch_size) * dw for w, dw in zip(self.weights, gradient_weights)]
self.biases = [b - (self.tau / self.batch_size) * db for b, db in zip(self.biases, gradient_bias)]
Diese Methode ruft wiederum die Methode update_mini_batch auf, die die Gradienten für ein mini-batch aufsummiert und
die Gewichte und Bias entsprechend anpasst. Dies muss aus den oben genannten Gründen in Form einer list comprehension
geschehen.
Die eigentliche Berechnung der Gradienten der Verlustfunktion für ein einzelnes Datenpaar erfolgt dann in der
backprop-Methode. Dabei führen wir zunächst einen forward pass durch, um die Aktivierungen und Ausgaben der
einzelnen Schichten zu berechnen und zu speichern. Anschließend berechnen wir und die Gradienten der
letzten Schicht gemäß Gl. (6.7). Die Rekursion für die versteckten Schichten
erfolgt dann über eine Schleife , wobei wir die backpropagation durch negative Indizes
erreichen.
def backprop(self, xi, yi):
# Compute the gradients of the network's parameters for input xi and target yi
# Initialize gradients for this input
gradient_weights_i = [np.zeros_like(w) for w in self.weights]
gradient_bias_i = [np.zeros_like(b) for b in self.biases]
### Feedforward pass ###
# First layer is just the input
h_l = xi
h_vectors = [h_l] # store hidden layer outputs
a_vectors = [] # store activations
# Perform forward pass through the network and store activations and outputs
for w_l, b_l, activation_l in zip(self.weights, self.biases, self.activations[1:]):
a_l = np.dot(w_l.T, h_l) + b_l
a_vectors.append(a_l)
h_l = activation_l(a_l)
h_vectors.append(h_l)
### Backward pass ###
# Compute delta for output layer
delta_l = (h_vectors[-1] - yi) * self.activations[-1].gradient(a_vectors[-1])
# Compute derivatives of parameters in output layer
gradient_weights_i[-1] = np.outer(h_vectors[-2], delta_l)
gradient_bias_i[-1] = delta_l
# Iterate over hidden layers
for l in range(2, self.num_layers):
# Compute delta for this layer
delta_l = self.activations[-l].gradient(a_vectors[-l]) * np.dot(delta_l, self.weights[-(l-1)].T)
# Compute derivatives of parameters in this layer
gradient_weights_i[-l] = np.outer(h_vectors[-(l+1)], delta_l)
gradient_bias_i[-l] = delta_l
return gradient_weights_i, gradient_bias_i
Der gesamte Trainingsprozess über mehrere Epochen ist dann in der Methode train zusammengefasst. Hierbei wird
für jede Epoche die train_step-Methode aufgerufen. Wir wollen das MLP zunächst auf ein Klassifikationsproblem
mit Klassen trainieren, weshalb die Dimension der Ausgabe der letzten Schicht ebenfalls betragen soll.
Die vorhergesagte Klasse ist dabei das Neuron mit dem höchsten Wert in der Ausgabe der letzten Schicht. Um die
Genauigkeit des Modells zu überprüfen, können wir also für jeden Trainingsdatenpunkt die Vorhersage mit dem
tatsächlichen Label vergleichen und den Anteil der korrekten Vorhersagen berechnen.
def train(self, X, y, epochs=10, validate=False):
# Train or validate the network for a number of epochs
for e in range(epochs):
# Train the network
if not validate:
self.train_step(X, y)
# Compute accuracy
N = X.shape[0]
accuracy = 0
for xi, yi in zip(X, y):
if np.argmax(self.feedforward(xi)) == np.argmax(yi):
accuracy += 1
accuracy /= N
print(f"Epoch: {e+1}, Accuracy: {accuracy:.3f}")
Anwendung auf den MNIST Datensatz
Wir können das MLP nun auf den MNIST Datensatz anwenden, um die Klassifikation von handgeschriebenen Ziffern zu
üben. Der Datensatz besteht aus 60.000 Trainings- und 10.000 Testbildern, die jeweils 28x28 Pixel groß sind.
Wir können die Bilder also als Vektoren der Länge 784 interpretieren. Die Labels sind die Ziffern von 0 bis 9,
die wir als One-Hot-Vektoren der Länge 10 kodieren. Das bedeutet, dass das Label
eines Bildes ein Vektor der Länge 10 ist, der an der Stelle eine 1 enthält
und an allen anderen Stellen 0. Sie können den MNIST Datensatz
hier herunterladen, sowie ein
Hilfsprogramm zum Laden des Datensatzes.
Liegen die Dateien mnist.pkl.gz und mnist_loader.py im gleichen Verzeichnis wie ihr Skript,
können Sie den Datensatz mit folgendem Befehl laden und die ersten fünf Bilder anzeigen lassen:
import mnist_loader as loader
# Load the MNIST dataset
train_data, valid_data, test_data = loader.load_MNIST('./mnist.pkl.gz')
# Plot the first 5 images in the training set
fig, ax = plt.subplots(1, 5, figsize=(10, 2))
for i in range(5):
ax[i].imshow(train_data[i][0].reshape(28, 28), cmap='gray')
ax[i].axis('off')
fig.tight_layout()
plt.show()
Zum Testen von ML-Modellen an großen Datenstrukturen ist es sinnvoll, das Modell nicht anhand aller zur Verfügung stehenden Daten zu trainieren. Führen wir das Training auf dem gesamten Datensatz durch, und das ggf. über mehrere Epochen, würden wir intuitiv erwarten, dass das Modell die Trainingsdaten perfekt approximiert. Das bedeutet jedoch nicht, dass das Modell auch auf unbekannten Daten gut generalisiert. Daher ist es üblich, die zu Verfügung stehenden Daten in einen Trainings- und einen Validierungsdatensatz aufzuteilen. Das Model wird dann nur auf den Trainingsdaten trainiert. Nach dem Training, oder auch nach jeder Epoche, kann dann die Genauigkeit des Modells auf den Validierungsdaten überprüft werden. Dies nennt man auch Kreuzvalidierung (engl. cross-validation) und es kann helfen, overfitting, also das Überanpassen des Modells an die Trainingsdaten, zu vermeiden. Erhalten wir auf den Validierungsdaten eine schlechtere Genauigkeit als auf den Trainingsdaten, könnte dies ein Hinweis auf overfitting sein.
Da unser MLP Bilder als Vektoren interpretiert, müssen wir die Dimension der Eingabe auf 784 setzen. Wir wählen eine Architektur mit einer versteckten Schicht mit 30 Neuronen und der Sigmoid-Aktivierungsfunktion. Die Ausgabe der letzten Schicht hat 10 Neuronen, die die Amplituden für die Ziffern 0 bis 9 darstellen.
tau = 1.0
batch_size = 10
epochs = 20
sizes = {784: None, # input dimension
30: Sigmoid(), # hidden layer
10: Sigmoid()} # output layer (classes)
f_hat = MLP(sizes, tau=tau, batch_size=batch_size)
Nachdem wir unseren Trainingsdatensatz in die korrecte Form gebracht haben, können wir das Modell für 20 Epochen trainieren.
N = len(train_data)
X_train = np.array([train_data[i][0] for i in range(N)])
y_train = np.array([train_data[i][1] for i in range(N)])
#print(X_train.shape, y_train.shape)
f_hat.train(X_train, y_train, epochs=epochs, validate=False)
Im Anschluss an den Trainingsprozess können wir die Genauigkeit des Modells auf den Validierungsdaten überprüfen.
N_valid = len(valid_data)
X_valid = np.array([valid_data[i][0] for i in range(N_valid)])
y_valid = np.array([valid_data[i][1] for i in range(N_valid)])
#print(X_valid.shape, y_valid.shape)
f_hat.train(X_valid, y_valid, epochs=1, validate=True)
Machen Sie sich bewusst, dass die Wahl der Hyperparameter, wie z.B. Lernrate, Anzahl der Neuronen pro Schicht, Anzahl der Schichten, Batch-Größe, etc., einen großen Einfluss auf die Performance des Modells haben. Da es keine allgemeingültigen Regeln für die Wahl der Hyperparameter gibt, ist es sinnvoll, verschiedene Werte zu testen und die Genauigkeit des Modells auf den Validierungsdaten zu überprüfen.
Hat man die optimalen Hyperparameter gefunden, wird das Model einmalig auf den Testdaten evaluiert, was die tatsächliche Performance des Modells auf unbekannten Daten darstellt.
Anwenden auf den QM9 Datensatz
Wir haben im obigen Abschnitt ein MLP auf den MNIST Datensatz angewendet, der ein Klassifikationsproblem darstellt. Dank der Flexibilität des MLP können wir es jedoch auch auf Regressionsprobleme anwenden. Dazu verwenden wir einen der wohl meist genutzten Datensätze in der Vorhersage von Moleküleigenschaften, den Quantum Machine 9 (QM9) Datensatz. Dieser enthält Daten von ca. 134.000 Molekülen mit bis zu 9 schweren Atomen (CNOF), die durch skalare 21 Eigenschaften beschrieben werden. Dazu gehören z.B. das Dipolmoment, die HOMO- und LUMO-Energien, die Enthalpie, Gibbs-Energie und Wärmekapazität. Sie können eine bereits prozessierte Version des Datensatzes hier herunterladen.
Übung
Aufgabe 3: Softmax und Kreuzentropie
Die binäre Kreuzentropie lässt sich mit Hilfe der Softmax-Funktion auch auf mehrere Klassen erweitern (multi-class classification). Die Softmax-Funktion
kann dabei als Aktivierungsfunktion der letzten Schicht eines neuronalen Netzes interpretiert werden, welche die Vorhersagen des Modells in eine normierte Wahrscheinlichkeitsverteilung umwandelt. Für den MNIST-Datensatz mit Klassen (Ziffern von 0 bis 9) bedeutet das, dass für die Ausgabe des Modells gilt, dass und .
Die Softmax-Funktion wird in der Regel in Kombination mit der Kreuzentropie-Verlustfunktion
verwendet, wobei der One-Hot-Encoded Vektor des Labels des -ten Datenpunkts ist. Wir müssen beachten, dass sich dadurch auch die Ableitung der Verlustfunktion ändert.
(a)
Zeigen Sie, dass die Ableitung der Kreuzentropie-Verlustfunktion eines Datenpunktes nach der Aktivierung der letzten Schicht (vgl. (6.6)) gegeben ist durch
wobei wir annehmen, dass .
(b)
Integrieren Sie die Softmax-Funktion und die Ableitung der Kreuzentropie-Verlustfunktion in Ihre Implementierung des MLPs und trainieren Sie das Modell auf dem MNIST-Datensatz.
Zusammenfassung und Ausblick
In diesem Kurs haben Sie neben den Grundlagen des Programmierens mit Python auch einige wichtige numerische Methoden, wie die Lösung von Differentialgleichungen oder die Fourier-Transformation, kennengelernt, sowie einen Einblick in das maschinelle Lernen erhalten. Dazu zählen Methoden des überwachten und unüberwachten Lernens, sowie neuronale Netzwerke, wobei wir uns auf die Anwendung dieser Methoden in der Chemie konzentriert haben. Wir möchten dabei betonen, dass wir Ihnen in diesem Kurs nur einen kleinen Einblick in die behandelten Themen geben konnten. Mit Ihrem erworbenen Wissen und Fähigkeiten sind Sie jedoch durchaus in der Lage, auch komplexere Probleme zu lösen und eigene Projekte zu realisieren, wozu wir Sie ausdrücklich ermutigen.
In der Chemie spielen maschinelle Lernverfahren eine immer wichtigere Rolle, da sie es ermöglichen, komplexe Zusammenhänge in großen Datenmengen zu erkennen und zu nutzen. Wir möchten Ihnen daher abschließend einen Einblick in den aktuellen Stand des maschinellen Lernens in der Chemie geben, sowie einige aktuelle Forschungsthemen aus unserem Arbeitskreis vorstellen.
Aktueller Stand des maschinellen Lernens in der Chemie
Zunächst möchten wir eines der spannendsten Anwendungsgebiete des maschinellen Lernens (in der Chemie und allgemein) motivieren, der Generierung von neuen und unbekannten Daten (engl. generative modeling). Dies ist abzugrenzen von den bisherigen diskriminativen Methoden, die darauf abzielen, bekannte Daten zu klassifizieren oder zu regressieren. Generative Modelle hingegen erlauben es, neue Daten zu generieren, die den bekannten Daten ähneln, aber nicht notwendigerweise identisch sind.
Variational Autoencoder (VAE)
Da dies ein Problem des unüberwachten Lernens ist, überlegen wir zunächst, wie wir neuronale Netzwerke für das unüberwachte Lernen, z.B. die Dimensionsreduktion einsetzen können. Erkennen wir die Flexibilität von neuronalen Netzwerken, so ist es naheliegend, einfach die Anzahl der Neuronen in der Ausgabeschicht zu reduzieren, um eine Dimensionsreduktion zu erreichen. Wir müssen uns jedoch bewusst sein, dass wir, um eine möglichst effiziente Dimensionsreduktion zu erreichen, eine Objektivfunktion definieren müssen. Eine Möglichkeit besteht darin, nach der Dimensionsreduktion die Daten wieder zu rekonstruieren. Solche Modelle werden als Autoencoder bezeichnet, da sie an ihrer schmalsten Stelle die wichtigsten Informationen über die Daten automatisch enkodieren müssen, um sie im Anschluss wieder dekodieren zu können (siehe Abbildung).
Die Verlustfunktion eines Autoencoders, die die Differenz zwischen den Eingabedaten und den rekonstruierten Daten bestimmt, wird als Rekonstruktionsfehler bezeichnet:
Wir können uns die komprierte Darstellung der Daten als einen versteckten Raum (engl. latent space) vorstellen, der durch die schmalste Schicht des Autoencoders definiert wird. In der folgenden Abbildung ist dies für Bilder des MNIST-Datensatzes dargestellt, wobei wir erkennen, dass der Autoencoder die Daten gemäß ihrer Klassen gruppieren kann, obwohl er nicht explizit darauf trainiert wurde.
Wie können wir diese Architektur nun nutzen, um neue Daten zu generieren? Ein naiver Ansatz wäre, einfach zufällige Werte für den versteckten Raum zu samplen und diese durch den Dekoder zu schicken. Dies führt jedoch zu keinen sinnvollen Ergebnissen, da der Autoencoder nicht explizit darauf trainiert wurde, dass der versteckte Raum eine sinnvolle Struktur aufweist. Wir benötigen also eine Form der Regularisierung, die sicherstellt, dass der versteckte Raum koninuierlich ist und das Generieren neuer Daten ermöglicht. Eine Möglichkeit besteht darin, die Verteilung der versteckten Variablen so zu modellieren, dass sie einer bekannten und einheitlichen Verteilung, z.B. einer Normalverteilung, entspricht. Dazu enkodieren wir die Daten nicht direkt als latente Vektoren , sondern lernen stattdessen den Mittelwert und die Varianz der Normalverteilung, die die versteckten Variablen beschreibt. Ein neuer Datenpunkt wird dann durch Sampling eines zufälligen latenten Vektors und Dekodierung durch den Dekoder generiert. Dieses Modell wird als Variational Autoencoder (VAE) bezeichnet (siehe Abbildung).
Die Parameter des VAE (Gewichte und Bias der neuronalen Netze) werden dann durch Minimierung der Verlustfunktion
optimiert, die aus zwei Teilen besteht. Der erste Teil ist der Rekonstruktionsfehler, analog zum Autoencoder. Der zweite Teil misst die Ähnlichkeit zwischen der Verteilung der versteckten Variablen und der gewünschten Normalverteilung, was in der Regel durch die Kullback-Leibler-Divergenz (KL-Divergenz) dargestellt wird. Dadurch erreichen wir, dass der versteckte Raum eine sinnvolle Struktur annimmt, was in der folgenden Abbildung für den MNIST-Datensatz dargestellt ist.
Da die Generierung von schwarz-weißen Bilder von Ziffern ein zugegebenermaßen langweiliges Beispiel darstellt, haben wir einen (einfachen) VAE auf dem CelebA-Datensatz trainiert, der ca. 200.000 Bilder von prominenten Persönlichkeiten enthält. In der folgenden Abbildung sehen Sie oben einige Beispiele von Bildern aus dem Datensatz und die im Trainingsprozess rekonstruierten Bilder. Unten sehen Sie einige Beispiele von zufällig generierten Bildern, die durch den VAE erzeugt wurden.
VAEs sind eines der ersten generativen Modelle, die neben der Generierung von Bildern auch für die Generierung neuer Moleküle eingesetzt wurden. Die Struktur des versteckten Raums erlaubt es, eine kontinuierliche Repräsentation von Molekülen zu erlernen, die es ermöglicht, neue Moleküle zu generieren.1
Auch wenn wir mit unserer eigenen Implementierungen von neuronalen Netzen in diesem Kurs theoretisch in der Lage wären, einen (variational) Autoencoder zu konstruieren und trainieren, ist dies doch sehr ineffizient und umständlich. Das liegt insbesondere daran, dass wir die Gradienten der Verlustfunktionen manuell berechnen müssten, was sehr zeitaufwändig ist.
In der Praxis verwendet man daher spezielle Bibliotheken, wie z.B. Pytorch, die es erlauben, Gradienten von allgemeinen Funktionen automatisch zu berechnen. In Pytorch wird dazu ein Rechengraph erstellt, der die Abhängigkeiten der Variablen und Funktionen darstellt. Die Bibliothek kann dann automatisch die Gradienten von beliebigen Funktionen berechnen, indem sie den Rechengraphen rückwärts durchläuft.
Sie können die Implementierung des oben gezeigten Modells in Pytorch hier herunterladen und auf Ihrem eigenen Rechner ausführen.
Achtung: Wir empfehlen Ihnen, Python-Pakete wie Pytorch, die
viele Abhängigkeiten von anderen Paketen haben, nicht in Ihrer
base-Umgebung zu installieren, sondern stattdessen
eine separate Umgebung
zu erstellen.
Diffusionsmodelle
Auch wenn VAEs in der Lage sind, versteckte Strukturen in den Daten zu lernen und neue Daten zu generieren, sind sie für der Generierung von hochqualitativen und realistischen Daten nicht optimal geeignet. Für sehr detailreiche Bilder eignen sich z.B. Generative Adversarial Networks (GANs), die allerdings auch ihre eigenen Schwächen haben. Als besonders vielversprechend haben sich in den letzten Jahren Diffusionsmodelle (eng. diffusion models) herausgestellt, die auf Prinzipien der statistischen Physik basieren. Die grundlegende Idee ist ähnlich zu VAEs, nämlich eine latente Darstellung der Daten zu nutzen, die einfach zu erlernen ist und die es erlaubt, neue Daten zu generieren. Anstatt jedoch die Dimensionalität der Daten zu verringern, fügt man in kleinen Schritten zufälliges Rauschen zu den Daten hinzu, bis die Struktur der Daten nicht mehr erkennbar ist. Für jeden Schritt kann dann ein neuronales Netzwerk trainiert werden, welches das im Diffusionsprozess hinzugefügte Rauschen vorhersagt. Die zugrundeliegende Verlustfunktion
ist dann denkbar einfach, was die Effektivität des Modells unterstreicht. Im umgekehrten Diffusionsprozess kann dann das Rauschen schrittweise entfernt werden, um eine verbesserte Version der Daten zu erhalten. Neue Daten können generiert werden, indem ausgehend von einem zufälligen Rauschen schrittweise das Rauschen entfernt wird, bis ein neuer Datenpunkt generiert wurde, der den Trainingsdaten ähnelt. Zudem erlauben Sie es, zusätzliche Informationen, wie z.B. eine Beschreibung des gewünschten Objekts auf einem Bild, in den Prozess zu integrieren.
Diffusionsmodelle sind heutzutage die Grundlage für die meisten kommerziell oder frei verfügbaren Modelle zur Generierung von Bildern, wie z.B. Stable Diffusion, DALL-E oder Imagen. In letzter Zeit wurden sie auch für die Generierung von Molekülen eingesetzt, wobei sie die Atomtypen und -positionen in einem Molekül vorhersagen. Ein solcher Generierungsprozess, der aus einer zufälligen Verteilung ein Molekül generiert, ist in der folgenden Animation dargestellt.
Aktuelle Forschungsthemen in unserem Arbeitskreis
Im Folgenden möchten wir Ihnen einen Einblick in die aktuellen Forschungsthemen am Lehrstuhl für Theoretische Chemie geben. Dazu zählen die Arbeitskreise von Prof. Dr. Roland Mitrić und Dr. Merle Röhr. Zudem stellen wir Ihnen kurze Codebeispiele vor (Python und andere Programmiersprachen), die beispielhaft für die Forschungsthemen stehen und in denen Sie sicherlich einige der in diesem Kurs erlernten Konzepte wiedererkennen werden.
Nicht-adiabadische Dynamik mit Trajectory Surface Hopping
Im Rahmen der Trajectory Surface Hopping Methode bewegen sich die Moleküle in einem -dimensionalen Konfigurationsraum, wobei die Anzahl der Atome im Molekül ist. Selbst bei kleinen Molekülen ist dieser Konfigurationsraum für Menschen nur schwer vorstellbar. Allerdings ist ein Großteil dieses Konfigurationsraums für die Dynamik uninteressant, da sie wegen ihrer hohen Energie (z.B. durch sehr große Bindungsabsände) für das Molekül nicht zugänglich sind. Das erlaubt uns, Dimensionalitätsreduktionstechniken zu verwenden, um den hochdimensionalen Konfigurationsraum auf eine niedrigdimensionale Darstellung zu projizieren, ohne dabei zu viele Informationen zu verlieren. Im folgenden Codebeispiel wird die multidimensionale Skalierung (MDS) verwendet, um eine niedrigdimensionale Darstellung des Konfigurationsraums zu berechnen.
def perform_traj_mds(atnos, ref_coords, traj_coords, ndim=2,
npi_pairs=None, dist_pairs=None, max_dist=None):
nref = len(ref_coords)
coords = np.concatenate((ref_coords, traj_coords))
descriptors, weights = get_geom_descriptor(
atnos, coords,
npi_pairs=NPI_PAIRS, dist_pairs=DIST_PAIRS, max_dist=MAX_DIST,
)
dissimilarities = get_desc_distmat(descriptors, weights)
mds = MDS(
n_components=ndim, dissimilarity='precomputed', random_state=42,
n_init=32, max_iter=1000, eps=1e-4,
)
embedding = mds.fit_transform(dissimilarities)
return embedding
Diese Technik wurde für die Untersuchung der Photodissoziationsdynamik von Cyclobutanon eingesetzt.2 Eine Darstellung von 4 repräsentativen Trajektorien sind in der folgenden Abbildung zu sehen.
Fragment-basierte Methoden für die Berechnung von angeregten Zuständen in großen molekularen Aggregaten
Die theoretische Beschreibung von dynamischen Prozessen, wie Exciton-Transfer oder Ladungstransfer, in organischen Halbleitern und komplexen molekularen System erfordert Methoden, die die Berechnung von großen molekularen Aggregaten ermöglichen, die aus tausenden Atomen bestehen. Die “traditionellen” quantenchemische Methoden, wie z.B. Hartree-Fock oder Dichtefunktionaltheorie, sind aufgrund ihrer starken Skalierung mit der Systemgröße nicht in der Lage, solche Systeme in angemessener Zeit zu berechnen. Um diese Problematik anzugehen, entwickelt unsere Gruppe neue theoretische Methoden, die solche großen molekularen Systeme beschreiben können. Dazu kombinieren wir semiempirische quantenchemische Methoden (DFTB) mit einem Fragmentierungsansatz (FMO) in einem neuen theoretischen Formalismus, der es erlaubt die angeregten Zustände von großen molekularen Aggregaten zu berechnen und die Molekulardynamik dieser angeregten Zustände zu simulieren.3,4
#![allow(unused)] fn main() { // create the A matrix from the orbital energy differences, // the Coulomb and the exchange contributions let h: Array2<f64> = self.fock_and_coulomb() - self.exchange(); // solve the eigenvalue problem A x = w A using the eigenvalue decomposition let (eigenvalues, eigenvectors) = h.eigh(UPLO::Upper).unwrap(); // Reference to the o-v transition charges. let q_ov: ArrayView2<f64> = self.properties.q_ov().unwrap(); // The transition charges for all excited states are computed. let q_trans: Array2<f64> = q_ov.dot(&eigenvectors); // The Mulliken transition dipole moments are computed. let tr_dipoles: Array2<f64> = mulliken_dipoles(q_trans.view(), &self.atoms); // The oscillator strengths are computed. let f: Array1<f64> = oscillator_strength(eigenvalues.view(), tr_dipoles.view()); }
Abbildung der Ladungstransferdynamik in einem Molekularen System aus 8 BTBT Monomeren. Die Population der Monomere beschreibt den Anteil der elektronischen Anregung, der sich auf den jeweiligen Monomeren befindet. Am Anfang der Dynamik befindet sich das System im Ladungstransferzustand zwischen dem ersten und dem letzten Monomer (Loch auf Monomer 1 und Elektron auf Monomer 8), weshalb die Population auf den Monomeren 50% beträgt. Im weiteren Verlauf der Ladungstransferdynamik bewegt sich die Anregung im molekularen Aggregat langsam vom ersten Monomer in Richtung des letzten Monomers, weil es sich bei diesem Molekül um einen Lochleiter handelt.
Simulation von stark gekoppelten Licht-Materie-Systemen
Energietransport in excitonischen Materialien spielt eine große Rolle für die Anwendung in vielen optoelektronischen Systemen. Während in der Vergangenheit versucht wurden, den Energietransport durch strukturelle Veränderung der verwendeten Moleküle zu verbessern, zielt ein Forschungsschwerpunkt unseres Arbeitskreises darauf ab, dies durch Kopplung der elektronischen Übergänge an starke elektromagnetische Felder zu erreichen, bspw. in Mikrokavitäten. Die Quasiteilchen, die in solchen Systemen entstehen, werden Polaritonen genannt. Genauer beschäftigen wir uns mit der theoretischen Beschreibung von Polaritonen. Dazu müssen viele Konzepte aus dem Studium der theoretischen Chemie zum Einsatz gebracht werden und zusätzlich kombiniert werden mit Methoden der Quantenelektrodynamik. Um reale Systeme zu berechnen, werden die entstehenden Gleichungen numerisch gelöst.
system.build_system()
system.set_operators()
system.set_H()
e, v = np.linalg.eigh(np.real(system.H))
coeff = np.dot(v.T, np.dot(system.a + system.a_dagger, v))
Unten rechts: Abhängigkeit der Licht-Materie-Kopplung von der relativen Orientierung
zwischen der elektrischen Feldstärke des Oberflächenplasmons und dem
Übergangsdipolmoment des energetisch tiefsten elektronisch angeregten Zustands
der Helix. Links: Dreisträngige helikale Struktur des supramolekularen
Perylenbisimid-Aggregats. Oben rechts: Simulierte Dispersionsrelation der Polaritonen
bei maximaler Licht-Materie-Kopplung.
Theoretische Untersuchung von kleinen Metallclustern
Dieser Forschungsbereich konzentriert sich auf kleine Metallcluster sowohl im Zeit- als auch im Energieraum (zeitabhängige und zeitunabhängige Prozesse). Ziel ist es, diese kleinen Metallcluster theoretisch so genau wie möglich zu beschreiben. Obwohl diese Cluster nur eine geringe Anzahl von Atomen enthalten, ist ihre Untersuchung aufgrund der komplizierten elektronischen Natur der d- und f-Schalen der Metalle sehr komplex. Diese Cluster sind besonders interessant, weil das Verständnis ihrer katalytischen Aktivität stark von theoretischen Studien profitieren könnte, die derzeit nicht in ausreichendem Maße verfügbar sind.
Dieses Code-Snippet stammt aus einem Programm, das die eindimensionale Schrödinger-Gleichung numerisch exakt löst. Diese Gleichung ist fundamental in der Quantenmechanik, insbesondere für das Verständnis von zweiatomigen Molekülen.
def getHamiltonian(self):
# Initialisiert eine Hamilton-Matrix mit komplexen Nullen
self.hamiltonian = np.zeros((dim, dim), dtype=complex)
# Berechnet die Impulswerte pk
pk = (2. * np.pi / (dim * self.deltax)) * (indrange - dim / 2.)
# Berechnet den kinetischen Term tk
tk = (pk**2) / (2.0 * self.m)
# Berechnet den Exponentialterm W
W = np.exp(2 * np.pi * 1.0j * indrange / dim)
# Schleife durch jede Zeile i der Hamilton-Matrix
for i in range(dim):
# Berechnet die temporären Werte für die Fourier-Transformation
tmp = tk * (W**i) * ((-1)**i)
# Führt die Fourier-Transformation durch und aktualisiert die Hamilton-Matrix-Zeile
self.hamiltonian[i,:] = oneminusone * np.fft.fft(tmp) / dim
Der vorliegende Code hat die Aufgabe, eine Hamilton-Matrix (self.hamiltonian)
zu konstruieren und eine Fourier-Transformation des kinetischen Teils
durchzuführen, da dieser Operator im Impulsraum multiplikativ ist. Zunächst
wird die Dimension des Grids (dim) ermittelt und eine komplexe Nullmatrix
für den Hamiltonian (self.hamiltonian) initialisiert. Anschließend werden
die Impulswerte (pk) berechnet und daraus der kinetische Term (tk) abgeleitet.
Durch eine exponentielle Funktion (W) und eine Schleife über die Matrixzeilen
wird der kinetische Term in den Impulsraum transformiert und mittels
Fourier-Transformation (np.fft.fft) in die Hamilton-Matrix eingetragen.
Abbildung: Theoretische Untersuchung von Ce2.
Links: Vergleich zwischen dem beobachteten NeNePo-Signal im Experiment
und den theoretischen Simulationen (Zeitraum).
Mitte: Berechnete potentielle Energiekurven. Die hervorgehobenen Zustände
stellen die identifizierten Zustände dar, die für das beobachtete Signal
verantwortlich sind.
Rechts: Vergleich zwischen dem experimentellen Photoelektronenspektrum von
Ce2 und dem simulierten Spektrum (Energieraum).
Optimierung von Dimeren für Singlet Fission
In der Arbeitsgruppe Röhr beschäftigen wir uns mit der Optimierung bestimmter Eigenschaften molekularer Aggregate. So hängen bestimmte Eigenschaften stark von der Anordnung der einzelnen Moleküle ab. Ein besonders interessantes Phänomen, mit dem wir uns beschäftigen, ist Singlet Fission. Dabei handelt es sich um einen Prozess in bestimmten organischen Molekülen, bei dem ein einzelnes angeregtes Singlet Exciton in zwei Triplett-Excitonen zerfällt. Dieser Prozess könnte theoretisch den Wirkungsgrad von Solarzellen erhöhen, da ein einzelnes Photon zwei Elektronen-Loch-Paare anregen kann. Unser Ziel ist es, eine Molekülanordnung zu finden, die diesen Singlet Fission sehr schnell durchführt. Solche Molekülanordnungen könnten dann in Solarzellen integriert werden, um deren Wirkungsgrad zu erhöhen.
scaler = StandardScaler()
X = scaler.fit_transform(X)
pca = PCA(n_components=n_components)
pca.fit(X)
transformed_data = pca.transform(X)
explained_variance_ratios = np.array(pca.explained_variance_ratio_)
# Get the principal components
components = np.array(pca.components_)
kmeans = KMeans(n_clusters=n_clusters)
kmeans.fit(transformed_data)
labels = np.array(kmeans.labels_)
centroids = np.array(kmeans.cluster_centers_)
In diesem Forschungsprojekt wurden zufällige PBI-Dimere konstruiert, diese hinsichtlich der sogenannten Singlet Fission-Rate optimiert und aus den optimierten Dimerstrukturen unter anderem Translation und Rotation extrahiert. Anschließend wurde mittels PCA und K-Means-Clustering nach Gemeinsamkeiten in den 500 Strukturen gesucht. Auf diese Weise konnten vier sehr charakteristische Gruppen von Dimeren identifiziert werden.5
Supramolekulare Aggregate des Lichtsammelkomplexes der grünen Schwefelbakterien
Mein Forschungsbereich konzentriert sich auf die Strukturaufklärung von Aggregaten, die in grünen Schwefelbakterien vorkommen. Diese Bakterien sind bemerkenswert für ihre Fähigkeit, in extremen Lichtverhältnissen Photosynthese zu betreiben, wobei sie spezielle Lichtsammelkomplexe, sogenannte Chlorosomen, nutzen. Diese Chlorosomen bestehen aus dicht gepackten Bacteriochlorophyll-Molekülen, die außergewöhnlich effiziente Energietransferprozesse aufweisen. Diese Aggregate bestehen oft aus mehreren konzentrischen Ringen, die eine zylindrische Form zeigen (siehe Abbildung).
Der Code Ausschnitt zeigt eine Implementierung des Frenkel-Exziton-Hamiltonian, der die Wechselwirkungen zwischen den Übergangsdipolmomenten der einzelenen Moleküle im Aggregat einbezieht. Damit kann ein Spektrum des Aggregates simuliert werden und dieses mit experimentellen Daten verglichen werden.
def getExcitonHamiltonian(self):
nmol = len(self.allMolecules)
self.hamiltonian = np.zeros((nmol, nmol))
for i, m in enumerate(self.allMolecules):
self.hamiltonian[i, i] = m.siteEnergy
# convert units! a.u/Angstrom --> eV/a.u
self.hamiltonian += self.getExcitonCoupling() * toang * toev
# create exciton states for aggregate
def getExcitonStates(self):
print("First step: Calculate hamiltonian.")
self.getExcitonHamiltonian()
print("Second step: Solve hamiltonian.")
self.excitonStateEnergies, self.excitonStateCoefficients = np.linalg.eigh(self.hamiltonian)
self.getSiteDipoles()
self.tdMoments = []
print("Third step: tdMoments.")
self.tdMoments = np.dot(np.transpose(self.excitonStateCoefficients), self.dipoles)
np.save("td", self.tdMoments)
np.save("excitonStates", self.excitonStateEnergies)
R. Bombarelli, et al., ACS Cent. Sci. 2018, 4, 2, 268–276.
X. Miao, K. Diemer, R. Mitrić, J. Chem. Phys. 2024, 160, 124309.
R. Einsele, J. Hoche, R. Mitrić, J. Chem. Phys. 2023, 158, 044121.
R. Einsele, R. Mitrić, J. Comput. Theor. Chem. 2024, just accepted.
J. Greiner, A. Singh, M.I.S. Röhr, Phys. Chem. Chem. Phys. 2024, 26, 19257-19265.
Übung 1
Aufgabe 1: Lineare und quadratische Regression
In dem obigen Beispiel haben wir die numpy Funktion np.linalg.solve verwendet, um die Lösung des
Gleichungssystems der linearen Regression numerisch zu berechnen. In diesem Zusammenhang bedeutet
dies, dass der Computer einer Reihe von Rechenschritten und Algorithmen folgt, um die approximative
Lösung des Gleichungssystems zu finden. Für das Gleichungssystem der linearen Regression gibt es jedoch
auch eine analytische Lösung, die direkt berechnet werden kann.
(a) Analytische Lösung der linearen Regression herleiten
Zeigen Sie, dass die Lösung des Gleichungssystems in Matrixform gegeben ist durch:
Lösen Sie dazu zunächst die erste Gleichung des Systems nach auf und setzen Sie das Ergebnis in die zweite Gleichung ein. Verwenden Sie außerdem die Definitionen der Mittelwerte und .
(b) Implementieren der analytischen Lösung für Messdaten von Methylenblau
Nutzen Sie die analytische Lösung, um die Parameter der linearen Regression für die Messdaten von Methylenblau
explizit zu berechnen. Vergleichen Sie die Ergebnisse mit den Ergebnissen, die Sie mit np.linalg.solve
erhalten haben.
(c) Matrixgleichung der quadratischen Regression herleiten
Die quadratische Regression ist eine Erweiterung der linearen Regression, bei der die abhängige Variable durch ein Polynom zweiten Grades in der unabhängigen Variable angenähert wird. Die allgemeine Form der quadratischen Regression ist gegeben durch:
In Analogie zur linearen Regression können wir die quadratische Regression als ein lineares Modell in den Parametern auffassen. Zeigen Sie, dass dieses Modell durch die folgende Matrixgleichung beschrieben wird:
Setzen Sie dazu die quadratische Funktion in die allgemeine Form der Verlustfunktion der Methode der kleinsten Quadrate ein und bilden Sie die Ableitungen nach den gesuchten Parametern.
(d) Quadratische Regression implementieren und an Methylenblau-Daten anwenden
Fahren Sie nun fort wie für die lineare Regression, indem Sie das Gleichungssystem der quadratischen
Regression aus Teilaufgabe (c) für die Methylenblau-Daten numerisch lösen.
Konstruieren Sie dazu zunächst die benötigte Matrix, bzw. den Vektor in Form von Arrays, und verwenden
Sie die Funktion np.linalg.solve. Plotten Sie anschließend die quadratische Regression zusammen
mit den Datenpunkten. Plotten Sie ebenfalls die Resiuduen und vergleichen Sie die Ergebnisse mit der
linearen Regression.
Aufgabe 2: Polynomiale Regression
Basierend auf der vorherigen Aufgabe, in welcher Sie von der linearen
Regression zur quadratischen Regression übergegangen sind, können Sie
bereits vermuten, dass die Methode der kleinsten Quadrate auch mit
höhergradige Polynomen simpel zu implementieren ist. In der Praxis ist es jedoch
nicht sinnvoll, die Gleichungssysteme für Polynome höheren Grades
manuell zu lösen. Stattdessen können Sie die Funktion
np.polyfit
verwenden, um die Koeffizienten eines Polynoms -ten Grades
zu bestimmen, welches am besten zu den Daten passt. Diese Funktion
nimmt als Argumente die Arrays der unabhängigen Variable und
der abhängigen Variable , sowie den Grad des Polynoms entgegen und
gibt die Koeffizienten zurück.
(a) Polynomiale Regression mit np.polyfit
Wenden Sie die Funktion np.polyfit auf die Methylenblau-Daten an, um
ein Polynom 20. Grades zu fitten und plotten Sie das Polynom zusammen mit den Datenpunkten.
Zum Plotten der Polynomfunktion können Sie die Funktion
np.polyval verwenden, welche
die Funktionswerte des Polynoms für gegebene Werte von und (d.h. concentrations) in
Form eines Arrays berechnet.
(b) Vorhersage von neuen Datenpunkten
Aus dem Plot der polynomialen Regression (und ggf. den Residuen) können Sie erkennen, dass das Polynom 20. Grades die Datenpunkte sehr gut anpasst. Dies ist auch nicht weiter verwunderlich, da wir eine Funktion mit mind. 20 Parametern so anpassen können, dass sie unsere 20 Datenpunkte perfekt wiedergibt. Allerdings haben wir in unserem Code bisher lediglich die Datenpunkte, welche wir zum Fitten des Modells verwendet haben, zur Visualisierung der Ergebnisse beachtet. Die Funktionswerte zwischen den Datenpunkten wurden lediglich interpoliert. In der Regel möchten wir allerdings mit Hilfe unseres Modells auch Vorhersagen für neue Datenpunkte erhalten.
Plotten Sie das gesamte Polynom 20. Grades zusammen mit den Datenpunkten in dem Interall
. Definieren Sie sich dazu ein Array mit 1000 Werten mit Hilfe
der Funktion np.linspace
und berechnen Sie die Funktionswerte. Beschränken Sie die Darstellung des Plots auf den Bereich .
Was beobachten Sie?
Aufgabe 3: Regularisierung
Das Phänomen, welches Sie bei der polynomialen Regression 20. Ordnung beobachten können, wird als Überanpassung (engl. overfitting) bezeichnet. Es tritt auf, wenn das Modell zu komplex ist und nicht nur der zugrunde liegende Trend, sondern auch das Rauschen in den Daten angepasst wird. In solchen Fällen kann das Modell die Datenpunkte zwar perfekt reproduzieren, aber es wird nicht in der Lage sein, neue Datenpunkte vorherzusagen.
Um Überanpassung zu vermeiden gibt es, neben der Reduzierung der Parameter, die Möglichkeit der Regularisierung. Darunter versteht man die Einführung von zusätzlichen Bedingungen, welche die Komplexität des Modells einschränken. Eine solche Bedingung kann beispielsweise sein, dass die Koeffizienten möglichst klein gehalten werden, was durch die Einführung eines zusätzlichen Terms in die Verlustfunktion erreicht werden kann.
Verwendet man das Quadrat der -Norm der Koeffizienten als Regularisierung und fügt sie der Verlustfunktion hinzu, so spricht man von Ridge-Regression. Die Verlustfunktion ist dann gegeben durch wobei der Parameter die relative Stärke der Regularisierung bestimmt.
(a) Ridge-Regression der Methylenblau-Daten mit Polynom 20. Ordnung
Implementieren Sie die Ridge-Regression für die Methylenblau-Daten mit
und fitten Sie ein Polynom 20. Ordnung. Nutzen Sie dazu die numerische Optimierungsmethode
mit der Funktion minimize und ändern Sie Ihre Objektivfunktion entsprechend. Verwenden Sie
als Startwerte ein Array mit Nullen. Normalisieren Sie außerdem vor der Regression die
Konzentrationen und die Absorptionswerte auf den Bereich , indem Sie jeweils durch den Maximalwert
teilen. Plotten Sie das Ergebnis zusammen mit den Datenpunkten.
Nutzen Sie zur Definition der Verlustfunktion erneut die Funktion np.polyval, sowie
die Funktion np.linalg.norm zur Berechnung der -Norm der Koeffizienten. Vergessen Sie nicht, den
Parameter in die Verlustfunktion einzuführen und der Funktion minimize zu übergeben.
(b) Einfluss des Regularisierungsparameters
Variieren Sie den Regularisierungsparameter und beobachten Sie, wie sich die Stärke der Regularisierung auf die Anpassung des Modells an die Datenpunkte auswirkt. Was passiert, wenn Sie oder wählen?
Übung 2
Aufgabe 1: Klassischer harmonischer Oszillator I
Die Bewegungsgleichung eines harmonischen Oszillators ist durch die Differentialgleichung zweiter Ordnung
gegeben, wobei . Um diese Gleichung mit dem Euler-Verfahren zu lösen, muss sie zunächst in ein System von Differentialgleichungen erster Ordnung umgeformt werden.
(a) Umformen in ein System von Differentialgleichungen erster Ordnung
Zeigen Sie, dass die obige Differentialgleichung in das System von gekoppelten Differentialgleichungen erster Ordnung
umgeformt werden kann, indem Sie die Substitution verwenden.
(b) Implementieren des Euler-Verfahrens
Implementieren Sie das Euler-Verfahren, um das System von Differentialgleichungen erster Ordnung
aus Teilaufgabe (a) zu lösen. Gehen Sie dazu wie in der Vorlesung vor,
indem Sie die Funktionen dfdt, welche die rechte Seite der Differentialgleichungen berechnet,
euler_step, welche einen Schritt des Euler-Verfahrens durchführt, und euler_method, welche das
Euler-Verfahren für eine gegebene Anzahl von Schritten durchführt, implementieren.
Ähnlich zur Kinetik der BZ-Reaktion hat die Lösung des harmonischen Oszillators zwei Komponenten,
und , dessen Ableitungen in Form eines Arrays [dxdt, dvdt] gespeichert werden können.
(c) Herleitung der analytischen Lösung
Zeigen Sie, dass sie analytische Lösung der Bewegungsgleichung des harmonischen Oszillators mit Anfangsbedingungen und gegeben ist durch
Nutzen Sie dazu den allgemeinen Lösungsansatz .
Plotten Sie die analytische Lösung und die numerische Lösung des Euler-Verfahrens für die Anfangsbedingungen , , mit einer Schrittweite von für . Was beobachten Sie?
Aufgabe 2: Klassischer harmonischer Oszillator II
Das Euler-Verfahren liefert für die Bewegungsgleichung des harmonischen Oszillators zwar eine stabile Lösung, allerdings ist sie nicht besonders genau. Wir könnten an dieser Stelle wieder die Schrittweite verkleinern, um die Genauigkeit zu erhöhen, jedoch wollen nun testen, wie sich das klassische Runge-Kutta-Verfahren (RK4) im Vergleich zum Euler-Verfahren schlägt.
Implementieren Sie das klassische Runge-Kutta-Verfahren 4. Ordnung anhand des folgenden Butcher-Tableaus und lösen Sie das System von Differentialgleichungen des klassischen harmonischen Oszillators aus der vorherigen Aufgabe mit den Anfangsbedingungen , , und einer Schrittweite von für . Plotten Sie die numerische Lösung und vergleichen Sie sie mit der Lösung des Euler-Verfahrens, sowie der analytischen Lösung.
Aufgabe 3: Schießverfahren
In der Vorlesung haben wir gesehen, dass die Randbedingungen der Schrödingergleichung des harmonischen Oszillators mit der Finite-Differenzen-Methode implizit erfüllt werden. Wir werden nun das sogennante Schießverfahren kennenlernen, mit welchem wir dieses Randwertproblem in ein Anfangswertproblem umwandeln können, um es mit den bekannten numerischen Methoden, wie den Runge-Kutta-Verfahren, zu lösen. Für eine allgemeine Differentialgleichung zweiter Ordnung mit den Randbedingungen und umfasst diese Methode, dass wir zunächst einen beliebigen Anfangswert wählen. Dann lösen wir die Differentialgleichung mit den Anfangsbedingungen und und überprüfen ob die Randbedingung erfüllt ist. Falls nicht, passen wir den Anfangswert an und wiederholen den Prozess, bis die Randbedingung erfüllt ist. Der Name des Verfahrens leitet sich von der Analogie zum Schießen einer Kanone ab, bei der der Abschusswinkel so lange angepasst wird, bis das Ziel getroffen wird.
Wir wollten nun das Schießverfahren verwenden, um die Wellenfunktion und Energie eines Teilchens in einem endlichen Potentialtopf der Länge zu bestimmen. Die Schrödingergleichung für dieses Problem lautet mit dem Potential für und ansonsten, sowie den Randbedingungen und . Im Rahmen eines Anfangswertproblems können wir die Wellenfunktion nur bis auf eine Normierungskonstante bestimmen, weshalb die konkrete Wahl des Anfangswertes irrelevant ist. Wir wissen allerdings, dass die Schrödingergleichung unendlich viele spezielle Lösungen hat, die jeweils durch die Quantenzahl und die entsprechender Energie charakterisiert sind. Die Energie ist demnach ein Parameter, den wir im Rahmen des Schießverfahrens solange anpassen können, bis die Randbedingung erfüllt ist. Normalerweise formuliert man dieses Ziel als ein Nullstellenproblem, welches mit numerischen Methoden gelöst werden kann. Da wir aber mehr als eine Lösung finden wollen, verwenden wir ein leicht abgewandeltes Verfahren.
(a) Implementieren des Schießverfahrens für das Teilchen im Kasten
Implementieren Sie das Schießverfahren, um die Schrödingergleichung für ein Teilchen in einem endlichen Potentialtopf der Länge zu lösen. Gehen Sie dazu wie folgt vor:
-
Überführen Sie die Schrödingergleichung in ein System von Differentialgleichungen erster Ordnung mit Hilfe der Substitution und implementieren Sie die Ableitungen der Funktionen und .
-
Lösen Sie das Anfangswertproblem mit einem Startwert und den Anfangsbedingungen und mit Hilfe der Funktion
solve_ivpüber dem Intervall . Erhöhen Sie dann iterativ die Energie in kleinen Schritten von und lösen Sie das Anfangswertproblem in jedem Schritt. -
Überprüfen Sie in jedem Schritt, ob die Randbedingung erfüllt ist, indem Sie den Absolutwert von mit einer Toleranz von vergleichen. Ist dies erfüllt, speichern Sie jeweils die Energie und die Wellenfunktion in einer Liste und führen Sie das Verfahren fort.
Bestimmen Sie mit diesem Verfahren die ersten fünf Energieniveaus und plotten Sie die zugehörigen Wellenfunktionen.
Sollte der Absolutwert von innerhalb ihrer gewählten Toleranz liegen, bietet es sich an, die Energie anschließend in einem größeren Schritt, z.B. , zu erhöhen. Ansonsten könnten Sie im darauffolgenden Schritt die gleiche Wellenfunktion erneut erhalten. Ändern Sie die Parameter der Schrittweite und Toleranz, um die Genauigkeit und Geschwindigkeit des Verfahrens zu beeinflussen.
(b) Implementieren des Schießverfahrens für das Teilchen im Stufenpotential
Erweitern Sie nun Ihre Implementierung, um die Wellenfunktionen und Energien des Teilchens in einem
rechtsseitigen Stufenpotential der Höhe und Breite zu bestimmen. Dazu müssen Sie
lediglich das Potential
in der Berechnung von berücksichtigen,
was Sie mit Hilfe einer if-Bedingung erreichen können.
Bestimmen Sie erneut die ersten fünf Energieniveaus und plotten Sie die zugehörigen Wellenfunktionen. Was beobachten Sie?
Übung 3
Aufgabe 1: IR-Spektrum von Mesitylen aus Molekulardynamik-Simulation
Das IR-Spektrum eines Moleküls ist eng mit der Änderung des Dipolmoments des Moleküls verbunden. Mit Hilfe quantenmechanischer Überlegungen kann gezeigt werden, dass das IR-Spektrum proportional zur Fourier-Transformation der Autokorrelationsfunktion des Dipolmoments ist:
Die Autokorrelationsfunktion des Dipolmoments ist gegeben durch
und beschreibt die zeitliche Korrelation (d.h. die Ähnlichkeit) des Dipolmoments zum Zeitpunkt mit dem Dipolmoment zu einem späteren Zeitpunkt . Das Wiener-Chintschin-Theorem besagt, dass die Autokorrelationsfunktion ebenso aus der Fourier-Transformation des Dipolmoments berechnet werden kann:
Setzen wir diese Beziehung in die erste Gleichung ein, so erhalten wir für das IR-Spektrum
da sich Fourier-Transformation und Inverse Fourier-Transformation gegenseitig aufheben.
(a) Berechnen des IR-Spektrums aus dem Dipolmoment
Berechnen Sie anhand der oben gegebenen Gleichung das IR-Spektrum von Mesitylen aus dem Dipolmoment , welches aus einer Molekulardynamik-Simulation (MD) erhalten wurde und hier heruntergeladen werden kann. Vergleichen Sie das erhaltene Spektrum mit dem experimentell gemessenen Spektrum von Mesitylen, welches Sie hierfinden können.
Verwenden Sie erneut die Funktionen aus der numpy.fft Bibliothek, um die (diskrete) Fourier-Transformation
durchzuführen. Beachten Sie, dass drei Komponenten hat, die jeweils separat transformiert
werden müssen. Berechnen Sie anschließend die quadrierte Norm des transformierten Dipolmoments
entlang dieser drei Komponenten, um eine reelle Größe zu erhalten.
(b) Fourier-Unschärfeprinzip
Wie verändert sich das erhaltene Spektum, wenn Sie nur die ersten 3 ps der MD Simulation verwenden? Und was beobachten Sie, wenn Sie nur jeden zweiten Wert der Dipolmomente verwenden?
Aufgabe 2: Eigenschaften der Fourier-Transformation
Zeigen Sie, dass Gleichungen (3.10) und (3.11) für die Fourier-Transformation gelten. Verwenden Sie dabei die Definition der Fourier-Transformation, sowie die Integration durch Substitution.
Zusatzaufgabe: Matrix- und Vektormultiplikation
In den folgenden Kapiteln werden wir häufig mit Matrizen und Vektoren verschiedener Dimensionen arbeiten. Daher ist es wichtig, die Regeln für die Multiplikation von Matrizen und Vektoren zu kennen.
Betrachten Sie die folgenden Matrizen und Vektoren, wobei :
Überprüfen Sie, ob die folgenden Multiplikationen möglich sind. Wenn ja, berechnen Sie das Ergebnis:
(i)
(ii)
(iii)
(iv)
(v)
(vi)
(vii)
(viii) , bzw.
Überprüfen Sie Ihre Ergebnisse in Pyhton. Nützliche Funktionen und Operationen sind u. a.
@, np.matmul, np.dot, *, np.transpose, np.linalg.norm und np.outer.
Übung 4
Aufgabe 1: Hückel-Theorie
Sie kennen bereits das LCAO-Verfahren (Linear Combination of Atomic Orbitals), bei dem Molekülorbitale als Linearkombination von Atomorbitalen dargestellt werden. Die Hückel-Theorie ist eine vereinfachte Methode zur Berechnung der Molekülorbitale von konjugierten Molekülen. Sie basiert auf der Beobachtung, dass die elektronische Struktur konjugierter Moleküle hauptsächlich durch die π-Elektronen bestimmt wird. Die Hückel-Molekülorbitale werden daher als Linearkombination der p-Orbitale der (schweren) Atome dargestellt:
Um die Koeffizienten zu bestimmen und die Molekülorbitale berechnen zu können, müssen wir die Schrödingergleichung für das Molekül aufstellen und lösen. Dabei machen wir die folgenden Annahmen:
- Die p-Orbitale der Atome sind orthogonal zueinander, d.h. .
- Die Wechselwirkung zwischen den p-Orbitalen wird nur zwischen den nächsten Nachbarn berücksichtigt, d.h. für und für .
- Die Diagonalelemente der Hamilton-Matrix sind .
(a) Herleiten des Eigenwertproblems
Zeigen Sie, dass unter diesen Annahmen die Schrödingergleichung zu einem Eigenwertproblem der Form
wird, wobei die Hamilton-Matrix (bzw. Hückel-Matrix), die Matrix der Koeffizienten und die Diagonalmatrix der Molekülorbital-Energien ist.
(b) Berechnung der Molekülorbitale von Hexatrien
Stellen Sie die Hückel-Matrix für das Hexatrien-Molekül auf und berechnen Sie die Molekülorbital-Energien und Koeffizienten im Rahmen der Hückel-Theorie. Verwenden Sie dazu die Parameter und . Versuchen Sie auch, die Molekülorbitale zu visualisieren, z.B. als eine Kette von Punkten mit Radien proportional zu den Koeffizienten. Geben Sie auch die Phase der Orbitale (Vorzeichen der Koeffizienten) als verschiedene Farben an.
(c) Berechnung der Molekülorbitale von Benzol
Was müssen Sie bei Ihrer Lösung in (b) ändern, um die Hückel-Matrix von Benzol aufzustellen? Berechnen Sie die Molekülorbital-Energien von Benzol und bestimmen Sie die Gesamtenergie des Moleküls gemäß
wobei die Anzahl der doppelt besetzten Molekülorbitale ist. Vergleichen Sie die Energie von Benzol mit der Energie von drei isolierten Doppelbindungen und erklären Sie den Unterschied.
Aufgabe 2: SVD und Eigengesichter
Die Singulärwertzerlegung (SVD) ist ein wichtiges Werkzeug in der Analyse von großen Datensätzen. Ein bekanntes Anwendungsbeispiel ist die Gesichtserkennung, bei der die SVD dazu verwendet wird, die sogenannten Eigengesichter zu berechnen. Diese repräsentieren die Hauptkomponenten der Gesichter in einem Datensatz und können dazu verwendet werden, Gesichter zu klassifizieren oder zu rekonstruieren.
Wir werden in dieser Aufgabe mit dem Yale B Datensatz arbeiten, der von 38 Personen jeweils ca. 64 Bildern in
verschiedenen Lichtverhältnissen enthält. Die Bilder sind in schwarz-weiß und haben eine Auflösung von 168 x 192
Pixeln. Sie können den Datensatz hier
herunterladen und mit Hilfe des folgenden Codes in einem Array faces speichern, wobei jede Spalte in faces
ein Bild darstellt:
import numpy as np
import matplotlib.pyplot as plt
import scipy as sp
# Import Yale Faces dataset
path = 'allFaces.mat'
data = sp.io.loadmat(path)
# Extract the data
faces = data['faces']
n = int(data['n'][0][0])
m = int(data['m'][0][0])
nfaces = data['nfaces'].flatten() # number of faces per person
# Plot first face
plt.imshow(faces[:,0].reshape(m,n).T, cmap='gray')
(a) Plotten der Bilder
Erkunden Sie den Datensatz, indem Sie fünf zufällige Bilder aus dem Datensatz nebeneinander plotten.
(b) Berechnung der Eigengesichter
Wir wollen zunächst die Hauptkomponenten der Gesichter im Datensatz bestimmen. Führen Sie dazu eine (economy)
Singulärwertzerlegung (4.6) der Matrix faces durch, wobei Sie nur die
ersten 36 Personen, d.h. 2282 Bilder verwenden. Beachten Sie außerdem, dass Sie vorher den Mittelwert dieser
Bilder von dem Array faces subtrahieren müssen. Die Eigengesichter entsprechen dann den Spalten der Matrix
. Plotten Sie auch die ersten fünf Eigengesichter.
(c) Rekonstruktion der Bilder
Da die (complete) SVD eine exakte Zerlegung der Datenmatrix ist, sollten wir in der Lage sein, die Bilder aus dem Datensatz mit Hilfe der Eigengesichter zu rekonstruieren. Die Eigengesichter dienen dabei als Basisvektoren, aus denen die Bilder als Linearkombinationen zusammengesetzt werden.
Nutzen wir hingegen nur die ersten Eigengesichter, so erhalten wir eine Approximation der Bilder. Die Koeffizienten der Linearkombination erhalten wir durch Projektion der Bilder auf die ersten Eigengesichter, d.h. Spalten der Matrix :
wobei ein Bildvektor ist. Das rekonstruierte Bild ergibt sich dann durch
Damit haben wir eine Reduktion der Dimensionalität erreicht, da wir anstatt der ursprünglichen 168 x 192 Pixel nur noch Koeffizienten speichern müssen.
Wählen Sie nun aus dem Datensatz ein Bild der zwei Personen, welche wir nicht für die SVD verwendet haben, z.B. das Bild mit Index 2282, und rekonstruieren Sie es mit Hilfe der ersten Eigengesichter. Plotten Sie die rekonstruierten Bilder für die verschiedenen . Was beobachten Sie?
Für die Effizienz der Rekonstruktion ist es sinnvoll, zuerst den Vektor zu berechnen und diesen dann mit den Eigengesichtern zu multiplizieren. Vergessen Sie nicht, von den Mittelwert abzuziehen, bevor Sie die Rekonstruktion durchführen und diesen anschließend zu wieder hinzuzufügen.
(d) Rekonstruktion von anderen Motiven
Wir können die Basis der Eigengesichter auch verwenden, um Bilder mit anderen Motiven darzustellen. Laden Sie dazu dieses Bild eines Hundes herunter, importieren Sie es mit
plt.imread('dog.jpg', format='jpeg')[:,:,0].T.flatten()
und führen Sie die Rekonstruktion wie in (c) durch. Was beobachten Sie?
Aufgabe 3: PCA mit Eigengesichtern
In der Vorlesung haben Sie gelernt, dass die Hauptkomponentenanalyse (PCA) eng mit der Singulärwertzerlegung verwandt ist. Nutzen Sie das fünfte und sechste Eigengesicht, d.h. die fünfte und sechste Spalte der Matrix aus der vorherigen Aufgabe, als Basisvektoren für die PCA des Yale B Datensatzes und projizieren Sie alle Bilder der fünften und siebten Person auf diese Basisvektoren. Plotten Sie die Projektionen der Bilder in einem Scatterplot und färben Sie die Punkte nach der Person, zu der das Bild gehört. Was beobachten Sie?
Aufgabe 4: PCoA und Molekulare Fingerprints
In Kapitel 4.4. haben Sie gesehen, wie die Hauptkoordinatenanalyse (PCoA) dazu verwendet werden kann, um eine niedrigdimensionale Darstellung eines Datensatzes basierend auf Distanzen zwischen den Datenpunkten zu erhalten. Dabei ist die Wahl der Repräsentation der Datenpunkte entscheidend für die Qualität der Darstellung.
(a) Erweitern der Molekularen Fingerprints
Ergänzen Sie Ihre Implementierung der PCoA des GDB-9 Datensatzes, sodass die Fingerprints der Moleküle
auch Bindungen von schweren Atomen zu Wasserstoffatomen enthalten. Erweitern Sie dazu lediglich das Dictionary
BOND_TYPES und verwenden Sie die rdkit-Funktion
Chem.AddHs(mol) um Wasserstoffatome hinzuzufügen.
Wie interpretieren Sie die Ergebnisse?
(b) Entwerfen von neuen Fingerprints
Wie Sie in (a) gesehen haben, ist das Ergebis der PCoA stark abhängig von der Wahl der Repräsentation der Datenpunkte. Nutzen Sie Ihre chemische Intuition, um weitere geeignete Fingerprints zu entwerfen und testen Sie sie anhand des GDB-9 Datensatzes.
Übung 5
Aufgabe 1: Lineare Regression
In der Vorlesung haben Sie eine multilineare Regression an zwei Features des Wine Quality Datensatzes durchgeführt. In dieser Aufgabe wollen wir das Thema näher betrachten.
(a) Analytische Lösung der Multilinearen Regression
In Kapitel 5.1 wurde die analytische Lösung der multilinearen Regression in Gl. (5.2) als gegeben. In Kapitel 4.5 wurde in Gl. (4.14) die Lösung aber als angegeben. Zeigen Sie, dass die beiden Lösungen äquivalent sind.
Hinweis: Sie können zeigen, indem Sie die Moore-Penrose-Bedingungen (Gl. (4.10) - (4.13)) für die Matrix überprüfen. Nutzen Sie zudem die Tatsache, dass die Matrix invertierbar ist.
(b) Multilineare Regression am Wein Quality Dataset
In der Vorlesung haben wir nur die Features alcohol und volatile acidity
für die multilineare Regression verwendet. Das war zwar gut für die
spätere Visualisierung, aber das Modell beschreibt den Datensatz nur mit
unzureichender Genauigkeit. Mit mehr Features können wir eine bessere
Vorhersage treffen. Führen Sie eine multilineare Regression an allen
Features des Wine Quality Datensatzes durch. Berechnen Sie anschließend die
mittlere quadratische Abweichung (engl. Mean Squared Error, MSE) dieser
Regression sowie der Regression an den beiden Features alcohol und
volatile acidity. Vergleichen Sie die beiden Ergebnisse.
(c) Bestimmtheitsmaß
Während der MSE uns eine quantitative Aussage über die Qualität der Regression gibt, ist die Interpretation des MSE nur anhand der Daten bzw. des Kontexts möglich. Ein alternatives Maß ist das Bestimmtheitsmaß (engl. coefficient of determination), auch als notiert, dessen Wert zwischen 0 und 1 liegt. Damit ist eine kontextunabhängige Interpretation möglich. Für die multilineare Regression ist definiert als wobei die tatsächlichen Labels, die vorhergesagten Labels und der Mittelwert der Labels sind.
Berechnen Sie das Bestimmtheitsmaß für die multilineare Regression an
allen Features des Wine Quality Datensatzes sowie für die Regression
an den beiden Features alcohol und volatile acidity. Vergleichen Sie
die beiden Ergebnisse.
Aufgabe 2: Support Vector Machines
In der Vorlesung haben wir die Support Vector Machines (SVM) als eine robustere Erweiterung des Rosenblatt-Perzeptrons kennengelernt. In dieser Aufgaben sollen Sie die SVM implementieren und anwenden.
(a) Herleitung der Gleichung für den Punkt-Hyperebenen-Abstand
Seien und gegeben und definiere die Hyperebene . Zeigen Sie, dass der Abstand zwischen einem Punkt und der Hyperebene , definiert als also als der minimale Abstand zwischen dem Punkt und einem Punkt auf der Hyperebene, gilt
Tipp: Zeigen Sie, dass den Abstand realisiert, d.h. und für alle . Die erste Aussage folgt leicht aus der Definition von . Für die zweite Aussage können Sie die binomische Formel verwenden.
(b) Implementierung der SVM
Implementieren Sie die SVM durch die Klasse SupportVectorMachine anhand der
Verlustfunktion in Gl. (5.6) sowie die Update-Regel danach.
Entnehmen Sie den Konstruktor aus den folgenden Code-Block:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
class SupportVectorMachine(object):
"""Support Vector Machine classifier.
Parameters
------------
dim : int
Dimension of the input data.
tau : float
Learning rate (between 0.0 and 1.0)
lam : float
Weight for maximising the margin.
epochs : int
Passes over the training dataset.
Attributes
-----------
w : 1d-array
Weights after fitting.
b : Scalar
Bias unit after fitting.
w_list : list
Weights in every epoch.
b_list : list
Bias units in every epoch.
errors : list
Number of misclassifications (updates) in each epoch.
margins : list
Width of the margin in each epoch.
"""
def __init__(self, dim=2, tau=0.1, lam=1.0, epochs=100):
self.tau = tau
self.lam = lam
self.epochs = epochs
self.w = np.random.randn(dim)
self.b = 0.0
self.w_list = [self.w.copy()] # need to copy to avoid reference
self.b_list = [self.b] # no need to copy, scalar
self.errors = []
self.losses = []
self.margins = []
Diese Klasse enthält einige zusätzliche Argumente und Attribute im Vergleich
zur Klasse Perceptron aus der Vorlesung. Das Argument lam ist
aus Gl. (5.6). Das Attribut losses soll der Wert der
Verlustfunktion für jede Epochen speichern, während das Attribut margins
einen Maß für den Abstand der Datenpunkte zur Hyperebene, und zwar
, speichern soll.
Tipp: Sie müssen nur Kleinigkeiten der Methode fit von Perceptron
anpassen. Die Methoden net_input und predict können Sie unverändert
übernehmen.
(c) Anwendung der SVM
Trainieren Sie eine SVM mit dem gleichen Datensatz wie in der Vorlesung, also die Gesichter von zwei Personen auf zwei Hauptkomponenten. Verwenden Sie dabei und . Führen Sie das Training für 50 Epochen durch und plotten Sie die Datenpunkte mit der Entscheidungsgrenze, sowie die Verlustfunktion über die Epochen.
(d) Kernel-Trick
Bislang haben wir immer angenommen, dass die Daten linear separierbar sind. Für Daten, die näherungsweise linear separierbar sind, können wir die Verlustfunktion der SVM etwas modifizieren, sodass eine approximative Trennung möglich ist. Haben wir allerdings Daten mit stark nichtlinearen Grenzen, wie z.B. in der folgenden Abbildung, so ist die SVM in ihrer klassischen Form nicht anwendbar.
In solchen Fällen können wir den sogenannten Kernel-Trick anwenden. Dabei wird das Standardskalarprodukt durch eine symmetrische Funktion ersetzt, die als Kernfunktion (engl. kernel function) bezeichnet wird. Das führt allerdings zu einer komplexeren Optimierungsaufgabe, die wir hier nicht behandeln.
In der Tat ist dieser Ansatz aber äquivalent dazu, die Datenpunkte in einen höherdimensionalen Raum einzubetten, sodass die Datenpunkte linear separierbar sind.
Wir betrachten die folgende Einbettung: Die Ebene mit ist die Kreisgleichung in . Die neue Dimension könnte uns also helfen, die Daten in der obigen Abbildung zu separieren.
Laden Sie die Daten aus der obigen Abbildung hier herunter und verwenden Sie die oben beschriebene Einbettung . Trainieren Sie die SVM an den eingebetteten Daten mit und für 200 Epochen. Plotten Sie die Datenpunkte mit den ursprünglichen Koordinaten in sowie die Projektion der Entscheidungsgrenze in . Plotten Sie zudem die Entwicklung der Verlustfunktion über die Epochen.
Aufgabe 3: -Means Clustering
In Analogie zu einer Verlustfunktion im überwachten Lernen, kann für den -Means-Algorithmus gezeigt werden, dass er die so genannte Cluster-Energie
minimiert.
(a) -Means mit variabler Anzahl an Clustern
Nutzen Sie diese Cluster-Energie, um den -Means-Algorithmus zu modifizieren, sodass die Anzahl der Cluster während des Trainings angepasst wird. Dazu können Sie z.B. nach jeder Iteration zufällige Cluster aufteilen oder zusammenführen, und diese neue Zuweisung der Datenpunkte akzeptieren, wenn die Cluster-Energie reduziert wird. Was beobachten Sie?
(b) Optimale Anzahl an Clustern
Überlegen Sie, für welche Anzahl an Clustern die Cluster-Energie minimal wird. Wie nennt man den dabei auftretenden Effekt und wie kann man ihn verhindern?
Übung 6
Aufgabe 1: Gradienten der logistischen Regression
Zeigen Sie, dass die Gradienten der Verlustfunktion der logistischen Regression (6.2) nach den Parametern und gegeben sind durch
Zeigen Sie dazu (unter Benutzung der Kettenregel) zunächst, dass die Ableitung der Sigmoid-Funktion gegeben ist durch
Vergleichen Sie die Gradienten der logistischen Regression mit den Gradienten der linearen Regression für die Verlustfunktion der Methode der kleinsten Quadrate. Was fällt Ihnen auf?
Aufgabe 2: Binäre Kreuzentropie
Wie wir schon in der Vorlesung gesehen haben, ist die Verlustfunktion der Methode der kleinsten Quadrate keine geeignete Methode, um Klassifikationsprobleme zu lösen. Für binäre Klassifikationsprobleme wird daher in der Regel die binäre Kreuzentropie
als Verlustfunktion verwendet. Dabei ist das wahre Label des -ten Datenpunkts, und die Vorhersage des Modells. Hier setzt die Verwendung des Logarithmus voraus, dass die Vorhersage des Modells auf das Intervall abgebildet wird, was durch die Sigmoid-Funktion erreicht werden kann.
(a)
Modifizieren Sie die Implementierung des SLP anhand des Circles-Datensatzes, sodass die binäre Kreuzentropie als Verlustfunktion verwendet wird. Beachten Sie, dass Sie dazu die Gradienten der Verlustfunktion nach den Parametern und berechnen müssen, wobei Ihnen die Ergebnisse der vorherigen Aufgabe helfen können.
(b)
Wie ist die Verlustfunktion der negativen log likelihood (6.2) der logistischen Regression mit der (binären) Kreuzentropie verwandt?
Aufgabe 3: Softmax und Kreuzentropie
Die binäre Kreuzentropie lässt sich mit Hilfe der Softmax-Funktion auch auf mehrere Klassen erweitern (multi-class classification). Die Softmax-Funktion
kann dabei als Aktivierungsfunktion der letzten Schicht eines neuronalen Netzes interpretiert werden, welche die Vorhersagen des Modells in eine normierte Wahrscheinlichkeitsverteilung umwandelt. Für den MNIST-Datensatz mit Klassen (Ziffern von 0 bis 9) bedeutet das, dass für die Ausgabe des Modells gilt, dass und .
Die Softmax-Funktion wird in der Regel in Kombination mit der Kreuzentropie-Verlustfunktion
verwendet, wobei der One-Hot-Encoded Vektor des Labels des -ten Datenpunkts ist. Wir müssen beachten, dass sich dadurch auch die Ableitung der Verlustfunktion ändert.
(a)
Zeigen Sie, dass die Ableitung der Kreuzentropie-Verlustfunktion eines Datenpunktes nach der Aktivierung der letzten Schicht (vgl. (6.6)) gegeben ist durch
wobei wir annehmen, dass .
(b)
Integrieren Sie die Softmax-Funktion und die Ableitung der Kreuzentropie-Verlustfunktion in Ihre Implementierung des MLPs und trainieren Sie das Modell auf dem MNIST-Datensatz.
Klausurvorbereitung
Die folgenden Aufgaben sollen Ihnen dabei helfen, sich auf die Klausur vorzubereiten. Die Aufgaben sind so gewählt, dass sie den Prüfungsfragen ähneln und Themen aus den Übungen und Vorlesungen abdecken.
Aufgabe 1: Code-Snippets
Wählen Sie für die folgenden Code-Snippets die erwartete Ausgabe aus.
| a) | b) | c) |
|---|---|---|
| | |
☐ 3 | ☐ 98.01 | ☐ [[1], [2], [3, 4]] |
☐ 6 | ☐ 81 | ☐ [[1], [2], [3], [4]] |
☐ 9 | ☐ -81 | ☐ [[0], [1], [2], [3, 4]] |
☐ Error | ☐ 100 | ☐ Error |
Aufgabe 2: Euler-Verfahren
In der folgenden Implementierung des Euler-Verfahrens haben sich fünf Fehler eingeschlichen. Finden Sie die Fehler und korrigieren Sie diese. Nehmen Sie dabei an, dass alle benötigten Module importiert wurden.
def dydx(x: float, y: float) -> float:
k = 0.0039022970
return -k * y
def euler_step(
x_n: float,
y_n: float,
h: float,
dydx: Callable[[float, float], float],
) -> float:
return x_n + h * dydx(x_n, y_n)
def euler_method(
x0: float,
y0: float,
h: float,
dydx: Callable[[float, float], float],
nsteps: int,
) -> np.ndarray:
x = x0 * np.arange(0, nsteps) * h
y = np.zeros(nsteps + 1)
y[1] = y0
for i in range(0, nsteps):
y[i] = euler_step(x[i], y[i], h, dydx)
return x, y
Aufgabe 3: -Nearest Neighbors
Der -Nearest Neighbors (KNN) Algorithmus ist ein einfacher Algorithmus für Klassifikationsprobleme, der aber auch für Regressionsprobleme verwendet werden kann. Er ist ein parameterfreier Algorithmus, das bedeutet, dass er keine Trainingsphase hat.
Für einen gegebenen Punkt sagt der KNN-Algorithmus die Klasse voraus, indem die nächsten Nachbarn von im Trainingsdatensatz basierend auf ihrer euklidischen Distanz zu bestimmt werden. Die Klasse von ist dann diejenige Klasse, die unter den nächsten Nachbarn am häufigsten vorkommt. Dies ist im folgenden Bild für zwei Klassen veranschaulicht, wobei für (innerer schwarzer Kreis) die Klasse des grünen Punktes als rot vorhergesagt wird.
(a)
Welche Werte für sind mehr oder weniger sinnvoll, wenn die Anzahl der Klassen zwei ist? Begründen Sie Ihre Antwort.
(b)
Vervollständigen Sie die Methode predict der Klasse kNN_Classifier in der folgenden
Implementierung.
import numpy as np
import matplotlib.pyplot as plt
class kNN_Classifier:
def __init__(self, k):
self.k = k
def predict(self, X, y, xi):
#TODO: Implement the kNN label prediction for xi
y_pred = ...
return y_pred
Das Model soll wie folgt verwendet werden:
N = 20
X = np.random.randn(N, 2)
y = np.hstack((np.zeros(N//2), np.ones(N//2)))
knn = kNN_Classifier(k=3)
xi = np.array([0, 0])
y_pred = knn.predict(X, y, xi)
print(y_pred) # Output: 0 or 1